diff --git a/src/embed/app.ts b/src/embed/app.ts index 8e4ef640..71e7f1cb 100644 --- a/src/embed/app.ts +++ b/src/embed/app.ts @@ -23,8 +23,7 @@ import { } from '../types'; import { V1Embed } from './ts-embed'; import { SpotterChatViewConfig, SpotterSidebarViewConfig } from './conversation'; -import { buildSpotterSidebarAppInitData } from './spotter-utils'; -import { SpotterVizConfig, buildSpotterVizAppInitData } from './spotter-viz-utils'; +import { SpotterVizConfig } from './spotter-viz-utils'; /** * Pages within the ThoughtSpot app that can be embedded. @@ -905,27 +904,6 @@ export class AppEmbed extends V1Embed { } } - /** - * Extends the default APP_INIT payload with `embedParams.spotterSidebarConfig` - * so the conv-assist app can read sidebar configuration on initialisation. - * - * Precedence for `enablePastConversationsSidebar`: - * `spotterSidebarConfig.enablePastConversationsSidebar` wins over the - * deprecated top-level `enablePastConversationsSidebar` flag; if the former - * is absent the latter is used as a fallback. - * - * An invalid `spotterDocumentationUrl` triggers a validation error and is - * excluded from the payload rather than forwarded to the app. - */ - protected async getAppInitData(): Promise { - const defaultAppInitData = await super.getAppInitData(); - const sidebarInitData = buildSpotterSidebarAppInitData( - defaultAppInitData, - this.viewConfig, - this.handleError.bind(this), - ); - return buildSpotterVizAppInitData(sidebarInitData, this.viewConfig); - } /** * Constructs a map of parameters to be passed on to the diff --git a/src/embed/conversation.ts b/src/embed/conversation.ts index 705d7758..9db4c775 100644 --- a/src/embed/conversation.ts +++ b/src/embed/conversation.ts @@ -2,7 +2,6 @@ import isUndefined from 'lodash/isUndefined'; import { ERROR_MESSAGE } from '../errors'; import { Param, BaseViewConfig, RuntimeFilter, RuntimeParameter, ErrorDetailsTypes, EmbedErrorCodes, DefaultAppInitData, VisualizationOverrides, SpotterFileUploadFileTypes } from '../types'; import { TsEmbed } from './ts-embed'; -import { buildSpotterSidebarAppInitData } from './spotter-utils'; import { getQueryParamString, getFilterQuery, getRuntimeParameters, setParamIfDefined } from '../utils'; /** @@ -405,22 +404,6 @@ export class SpotterEmbed extends TsEmbed { super(container, viewConfig); } - /** - * Extends the default APP_INIT payload with `embedParams.spotterSidebarConfig` - * so the conv-assist app can read sidebar configuration on initialisation. - * - * Precedence for `enablePastConversationsSidebar`: - * `spotterSidebarConfig.enablePastConversationsSidebar` wins over the - * deprecated top-level `enablePastConversationsSidebar` flag; if the former - * is absent the latter is used as a fallback. - * - * An invalid `spotterDocumentationUrl` triggers a validation error and is - * excluded from the payload rather than forwarded to the app. - */ - protected async getAppInitData(): Promise { - const defaultAppInitData = await super.getAppInitData(); - return buildSpotterSidebarAppInitData(defaultAppInitData, this.viewConfig, this.handleError.bind(this)); - } protected getEmbedParamsObject() { const { diff --git a/src/embed/embedParams-builder.ts b/src/embed/embedParams-builder.ts new file mode 100644 index 00000000..629f4fbe --- /dev/null +++ b/src/embed/embedParams-builder.ts @@ -0,0 +1,55 @@ +/** + * Declarative embedParams builder system. + * + * Owns the single place that knows which viewConfig properties map to which + * embedParams payloads. Embed subclasses do not build embedParams themselves; + * the base class calls collectEmbedParamsPayloads() once. + */ + +import type { SpotterVizConfig } from './spotter-viz-utils'; +import { buildSpotterVizEmbedParamsContribution } from './spotter-viz-utils'; +import type { SpotterSidebarViewConfig } from './conversation'; +import { buildSpotterSidebarEmbedParamsContribution } from './spotter-utils'; +import type { VisualizationOverrides } from '../types'; + +export interface EmbedParamsContribution { + spotterSidebarConfig?: SpotterSidebarViewConfig; + spotterVizConfig?: SpotterVizConfig; + visualOverridesParams?: VisualizationOverrides | null; +} + +/** + * The viewConfig properties that feed embedParams. All optional so any + * embed's viewConfig is structurally assignable without casts. + */ +export interface EmbedParamsSourceConfig { + spotterSidebarConfig?: SpotterSidebarViewConfig; + enablePastConversationsSidebar?: boolean; + visualOverrides?: VisualizationOverrides; + spotterViz?: SpotterVizConfig; +} + +export function buildEmbedParams( + contributions: Partial[] +): Partial | undefined { + const merged = contributions.reduce((acc, contrib) => { + if (!contrib || Object.keys(contrib).length === 0) return acc; + return { ...acc, ...contrib }; + }, {} as Partial); + + return Object.keys(merged).length === 0 ? undefined : merged; +} + +/** + * Detects which embedParams payloads apply for the given viewConfig and + * merges them. Returns undefined when nothing applies. + */ +export function collectEmbedParamsPayloads( + viewConfig: EmbedParamsSourceConfig, + handleError: (err: any) => void, +): Partial | undefined { + return buildEmbedParams([ + buildSpotterSidebarEmbedParamsContribution(viewConfig, handleError), + buildSpotterVizEmbedParamsContribution(viewConfig), + ]); +} diff --git a/src/embed/liveboard.ts b/src/embed/liveboard.ts index 9d870aac..a6d1248c 100644 --- a/src/embed/liveboard.ts +++ b/src/embed/liveboard.ts @@ -32,7 +32,7 @@ import { addPreviewStylesIfNotPresent } from '../utils/global-styles'; import { TriggerPayload, TriggerResponse } from './hostEventClient/contracts'; import { logger } from '../utils/logger'; import { SpotterChatViewConfig } from './conversation'; -import { SpotterVizConfig, buildSpotterVizAppInitData } from './spotter-viz-utils'; +import { SpotterVizConfig } from './spotter-viz-utils'; /** * APP_INIT data shape for LiveboardEmbed. @@ -635,10 +635,6 @@ export class LiveboardEmbed extends V1Embed { } } - protected async getAppInitData(): Promise { - const defaultAppInitData = await super.getAppInitData(); - return buildSpotterVizAppInitData(defaultAppInitData, this.viewConfig); - } /** * Construct a map of params to be passed on to the diff --git a/src/embed/search.ts b/src/embed/search.ts index 680b42bb..2c3ec0ed 100644 --- a/src/embed/search.ts +++ b/src/embed/search.ts @@ -405,19 +405,10 @@ export class SearchEmbed extends TsEmbed { protected async getAppInitData(): Promise { const defaultAppInitData = await super.getAppInitData(); - const result: SearchAppInitData = { + return { ...defaultAppInitData, ...this.getSearchInitData(), }; - - if (this.viewConfig.visualOverrides) { - result.embedParams = { - ...((defaultAppInitData as any).embedParams || {}), - visualOverridesParams: this.viewConfig.visualOverrides, - }; - } - - return result; } protected getEmbedParamsObject() { diff --git a/src/embed/spotter-utils.spec.ts b/src/embed/spotter-utils.spec.ts index 495d5afb..fcc309f6 100644 --- a/src/embed/spotter-utils.spec.ts +++ b/src/embed/spotter-utils.spec.ts @@ -1,4 +1,4 @@ -import { resolveEnablePastConversationsSidebar, buildSpotterSidebarAppInitData } from './spotter-utils'; +import { resolveEnablePastConversationsSidebar, buildSpotterSidebarEmbedParamsContribution } from './spotter-utils'; import { ErrorDetailsTypes, EmbedErrorCodes } from '../types'; import { ERROR_MESSAGE } from '../errors'; @@ -17,33 +17,32 @@ describe('resolveEnablePastConversationsSidebar', () => { }); }); -describe('buildSpotterSidebarAppInitData', () => { - const base = { type: 'APP_INIT' } as any; +describe('buildSpotterSidebarEmbedParamsContribution', () => { const noopError = jest.fn(); - it('returns base unchanged when no sidebar config or standalone flag', () => { - const result = buildSpotterSidebarAppInitData(base, {}, noopError); - expect(result).toBe(base); + it('returns empty object when no sidebar config or standalone flag', () => { + const result = buildSpotterSidebarEmbedParamsContribution({}, noopError); + expect(result).toEqual({}); }); - it('nests spotterSidebarConfig under embedParams', () => { - const result = buildSpotterSidebarAppInitData(base, { + it('returns spotterSidebarConfig contribution', () => { + const result = buildSpotterSidebarEmbedParamsContribution({ spotterSidebarConfig: { enablePastConversationsSidebar: true, spotterSidebarTitle: 'Chats' }, }, noopError); - expect(result.embedParams?.spotterSidebarConfig).toEqual({ + expect(result.spotterSidebarConfig).toEqual({ enablePastConversationsSidebar: true, spotterSidebarTitle: 'Chats', }); }); it('promotes standalone flag into spotterSidebarConfig.enablePastConversationsSidebar', () => { - const result = buildSpotterSidebarAppInitData(base, { enablePastConversationsSidebar: true }, noopError); - expect(result.embedParams?.spotterSidebarConfig?.enablePastConversationsSidebar).toBe(true); + const result = buildSpotterSidebarEmbedParamsContribution({ enablePastConversationsSidebar: true }, noopError); + expect(result.spotterSidebarConfig?.enablePastConversationsSidebar).toBe(true); }); it('calls handleError and strips spotterDocumentationUrl when invalid', () => { const handleError = jest.fn(); - const result = buildSpotterSidebarAppInitData(base, { + const result = buildSpotterSidebarEmbedParamsContribution({ spotterSidebarConfig: { spotterDocumentationUrl: 'not-a-url' }, }, handleError); expect(handleError).toHaveBeenCalledWith(expect.objectContaining({ @@ -51,22 +50,19 @@ describe('buildSpotterSidebarAppInitData', () => { message: ERROR_MESSAGE.INVALID_SPOTTER_DOCUMENTATION_URL, code: EmbedErrorCodes.INVALID_URL, })); - expect(result.embedParams?.spotterSidebarConfig?.spotterDocumentationUrl).toBeUndefined(); + expect(result.spotterSidebarConfig?.spotterDocumentationUrl).toBeUndefined(); }); - it('returns base with visualOverridesParams when only visualOverrides is provided', () => { + it('returns visualOverridesParams when only visualOverrides is provided', () => { const visualOverrides = { chart: { legend: { show: true, position: 'bottom' as const }, }, }; - const result = buildSpotterSidebarAppInitData(base, { + const result = buildSpotterSidebarEmbedParamsContribution({ visualOverrides, }, noopError); - expect(result).toEqual({ - ...base, - embedParams: { visualOverridesParams: visualOverrides }, - }); + expect(result).toEqual({ visualOverridesParams: visualOverrides }); }); it('includes visualOverridesParams with spotterSidebarConfig', () => { @@ -75,12 +71,12 @@ describe('buildSpotterSidebarAppInitData', () => { display: { tableTheme: 'ZEBRA' }, }, }; - const result = buildSpotterSidebarAppInitData(base, { + const result = buildSpotterSidebarEmbedParamsContribution({ spotterSidebarConfig: { enablePastConversationsSidebar: true }, visualOverrides, }, noopError); - expect(result.embedParams?.spotterSidebarConfig?.enablePastConversationsSidebar).toBe(true); - expect(result.embedParams?.visualOverridesParams).toEqual(visualOverrides); + expect(result.spotterSidebarConfig?.enablePastConversationsSidebar).toBe(true); + expect(result.visualOverridesParams).toEqual(visualOverrides); }); it('includes visualOverridesParams with standalone enablePastConversationsSidebar flag', () => { @@ -89,20 +85,20 @@ describe('buildSpotterSidebarAppInitData', () => { legend: { show: false }, }, }; - const result = buildSpotterSidebarAppInitData(base, { + const result = buildSpotterSidebarEmbedParamsContribution({ enablePastConversationsSidebar: true, visualOverrides, }, noopError); - expect(result.embedParams?.spotterSidebarConfig?.enablePastConversationsSidebar).toBe(true); - expect(result.embedParams?.visualOverridesParams).toEqual(visualOverrides); + expect(result.spotterSidebarConfig?.enablePastConversationsSidebar).toBe(true); + expect(result.visualOverridesParams).toEqual(visualOverrides); }); it('does not include visualOverridesParams when it is undefined', () => { - const result = buildSpotterSidebarAppInitData(base, { + const result = buildSpotterSidebarEmbedParamsContribution({ spotterSidebarConfig: { enablePastConversationsSidebar: true }, visualOverrides: undefined, }, noopError); - expect(result.embedParams?.visualOverridesParams).toBeUndefined(); - expect(result.embedParams?.spotterSidebarConfig?.enablePastConversationsSidebar).toBe(true); + expect(result.visualOverridesParams).toBeUndefined(); + expect(result.spotterSidebarConfig?.enablePastConversationsSidebar).toBe(true); }); }); diff --git a/src/embed/spotter-utils.ts b/src/embed/spotter-utils.ts index a31b5717..701757f9 100644 --- a/src/embed/spotter-utils.ts +++ b/src/embed/spotter-utils.ts @@ -1,8 +1,9 @@ -import { DefaultAppInitData, ErrorDetailsTypes, EmbedErrorCodes } from '../types'; +import { ErrorDetailsTypes, EmbedErrorCodes } from '../types'; import { validateHttpUrl } from '../utils'; import { ERROR_MESSAGE } from '../errors'; import type { SpotterSidebarViewConfig } from './conversation'; import type { VisualizationOverrides } from '../types'; +import type { EmbedParamsContribution } from './embedParams-builder'; /** * Resolves enablePastConversationsSidebar with @@ -18,20 +19,14 @@ export const resolveEnablePastConversationsSidebar = (params: { : params.standaloneValue ); -export function buildSpotterSidebarAppInitData( - defaultAppInitData: T, +export function buildSpotterSidebarEmbedParamsContribution( viewConfig: { spotterSidebarConfig?: SpotterSidebarViewConfig; enablePastConversationsSidebar?: boolean; visualOverrides?: VisualizationOverrides; }, handleError: (err: any) => void, -): T & { - embedParams?: { - spotterSidebarConfig?: SpotterSidebarViewConfig; - visualOverridesParams?: VisualizationOverrides | null; - }; -} { +): Partial { const { spotterSidebarConfig, enablePastConversationsSidebar, visualOverrides } = viewConfig; const resolvedEnablePastConversations = resolveEnablePastConversationsSidebar({ @@ -40,42 +35,41 @@ export function buildSpotterSidebarAppInitData( }); const hasConfig = spotterSidebarConfig || resolvedEnablePastConversations !== undefined; - if (!hasConfig) { - if (visualOverrides === undefined) { - return defaultAppInitData; - } - return { - ...defaultAppInitData, - embedParams: { visualOverridesParams: visualOverrides }, - }; + if (!hasConfig && visualOverrides === undefined) { + return {}; } - const resolvedSidebarConfig: SpotterSidebarViewConfig = { - ...spotterSidebarConfig, - ...(resolvedEnablePastConversations !== undefined && { - enablePastConversationsSidebar: resolvedEnablePastConversations, - }), - }; + const contribution: Partial = {}; + + if (hasConfig) { + const resolvedSidebarConfig: SpotterSidebarViewConfig = { + ...spotterSidebarConfig, + ...(resolvedEnablePastConversations !== undefined && { + enablePastConversationsSidebar: resolvedEnablePastConversations, + }), + }; - if (resolvedSidebarConfig.spotterDocumentationUrl !== undefined) { - const [isValid, validationError] = validateHttpUrl(resolvedSidebarConfig.spotterDocumentationUrl); - if (!isValid) { - handleError({ - errorType: ErrorDetailsTypes.VALIDATION_ERROR, - message: ERROR_MESSAGE.INVALID_SPOTTER_DOCUMENTATION_URL, - code: EmbedErrorCodes.INVALID_URL, - error: validationError?.message || ERROR_MESSAGE.INVALID_SPOTTER_DOCUMENTATION_URL, - }); - delete resolvedSidebarConfig.spotterDocumentationUrl; + if (resolvedSidebarConfig.spotterDocumentationUrl !== undefined) { + const [isValid, validationError] = validateHttpUrl( + resolvedSidebarConfig.spotterDocumentationUrl + ); + if (!isValid) { + handleError({ + errorType: ErrorDetailsTypes.VALIDATION_ERROR, + message: ERROR_MESSAGE.INVALID_SPOTTER_DOCUMENTATION_URL, + code: EmbedErrorCodes.INVALID_URL, + error: validationError?.message || ERROR_MESSAGE.INVALID_SPOTTER_DOCUMENTATION_URL, + }); + delete resolvedSidebarConfig.spotterDocumentationUrl; + } } + + contribution.spotterSidebarConfig = resolvedSidebarConfig; + } + + if (visualOverrides !== undefined) { + contribution.visualOverridesParams = visualOverrides; } - return { - ...defaultAppInitData, - embedParams: { - ...((defaultAppInitData as any).embedParams || {}), - spotterSidebarConfig: resolvedSidebarConfig, - ...(visualOverrides !== undefined ? { visualOverridesParams: visualOverrides } : {}), - }, - }; + return contribution; } diff --git a/src/embed/spotter-viz-utils.spec.ts b/src/embed/spotter-viz-utils.spec.ts index 7699c21c..f9fc9d97 100644 --- a/src/embed/spotter-viz-utils.spec.ts +++ b/src/embed/spotter-viz-utils.spec.ts @@ -1,30 +1,20 @@ -import { buildSpotterVizAppInitData } from './spotter-viz-utils'; +import { buildSpotterVizEmbedParamsContribution } from './spotter-viz-utils'; -describe('buildSpotterVizAppInitData', () => { - const base = { type: 'APP_INIT' } as any; - - it('returns initData unchanged when spotterViz is not provided', () => { - const result = buildSpotterVizAppInitData(base, {}); - expect(result).toBe(base); +describe('buildSpotterVizEmbedParamsContribution', () => { + it('returns empty object when spotterViz is not provided', () => { + const result = buildSpotterVizEmbedParamsContribution({}); + expect(result).toEqual({}); }); - it('nests spotterViz under embedParams.spotterVizConfig', () => { + it('returns spotterVizConfig contribution', () => { const spotterViz = { brandName: 'MyBrand', description: 'Desc', inputChatPlaceholder: 'Ask...' }; - const result = buildSpotterVizAppInitData(base, { spotterViz }); - expect(result.embedParams?.spotterVizConfig).toEqual(spotterViz); + const result = buildSpotterVizEmbedParamsContribution({ spotterViz }); + expect(result.spotterVizConfig).toEqual(spotterViz); }); it('passes brandHeadline through spotterVizConfig', () => { const spotterViz = { brandName: 'MyBrand', brandHeadline: "Hi, there! I'm" }; - const result = buildSpotterVizAppInitData(base, { spotterViz }); - expect(result.embedParams?.spotterVizConfig?.brandHeadline).toBe("Hi, there! I'm"); - }); - - it('preserves existing embedParams when adding spotterVizConfig', () => { - const existing = { ...base, embedParams: { spotterSidebarConfig: { enablePastConversationsSidebar: true } } }; - const spotterViz = { brandName: 'MyBrand' }; - const result = buildSpotterVizAppInitData(existing, { spotterViz }); - expect(result.embedParams?.spotterVizConfig).toEqual(spotterViz); - expect(result.embedParams?.spotterSidebarConfig?.enablePastConversationsSidebar).toBe(true); + const result = buildSpotterVizEmbedParamsContribution({ spotterViz }); + expect(result.spotterVizConfig?.brandHeadline).toBe("Hi, there! I'm"); }); }); diff --git a/src/embed/spotter-viz-utils.ts b/src/embed/spotter-viz-utils.ts index dc2f0811..b7aa0c5c 100644 --- a/src/embed/spotter-viz-utils.ts +++ b/src/embed/spotter-viz-utils.ts @@ -1,4 +1,4 @@ -import { DefaultAppInitData } from '../types'; +import type { EmbedParamsContribution } from './embedParams-builder'; /** * Defines starter prompts displayed in the SpotterViz interface. @@ -78,17 +78,10 @@ export interface SpotterVizConfig { inputChatPlaceholder?: string; } -export function buildSpotterVizAppInitData( - initData: T, +export function buildSpotterVizEmbedParamsContribution( viewConfig: { spotterViz?: SpotterVizConfig }, -): T & { embedParams?: { spotterVizConfig?: SpotterVizConfig } } { +): Partial { const { spotterViz } = viewConfig; - if (!spotterViz) return initData; - return { - ...initData, - embedParams: { - ...((initData as T & { embedParams?: Record }).embedParams || {}), - spotterVizConfig: spotterViz, - }, - }; + if (!spotterViz) return {}; + return { spotterVizConfig: spotterViz }; } diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 96d834a2..946315b2 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -89,6 +89,7 @@ import { processApiInterceptResponse, processLegacyInterceptResponse, } from '../api-intercept'; +import { collectEmbedParamsPayloads, type EmbedParamsSourceConfig } from './embedParams-builder'; /** * Global prefix for all ThoughtSpot postHash Params. @@ -511,7 +512,14 @@ export class TsEmbed { }, }); } - const baseInitData = { + // Subclass viewConfigs (App/Liveboard/Search/Spotter) carry the embedParams + // source fields; the base ViewConfig type does not declare them, so narrow here. + const embedParams = collectEmbedParamsPayloads( + this.viewConfig as EmbedParamsSourceConfig, + this.handleError.bind(this), + ); + + const baseInitData: DefaultAppInitData = { customisations: getCustomisations(this.embedConfig, this.viewConfig), authToken, runtimeFilterParams: this.viewConfig.excludeRuntimeFiltersfromURL @@ -533,6 +541,7 @@ export class TsEmbed { embedExpiryInAuthToken: this.viewConfig.refreshAuthTokenOnNearExpiry ?? true, ...getInterceptInitData(this.viewConfig), ...getHostEventsConfig(this.viewConfig), + ...(embedParams && { embedParams }), }; return baseInitData;