diff --git a/src/embed/app.spec.ts b/src/embed/app.spec.ts index b761df10..3389761b 100644 --- a/src/embed/app.spec.ts +++ b/src/embed/app.spec.ts @@ -55,7 +55,7 @@ const testUrlParams = async (viewConfig: AppViewConfig, expectedUrl: string) => }); }; -// Helper function to test setIframeHeightForNonEmbedLiveboard behavior +// Helper function to test the full-height route-change behavior const testSetIframeHeightBehavior = ( currentPath: string, shouldBeCalled: boolean @@ -70,7 +70,7 @@ const testSetIframeHeightBehavior = ( : jest.spyOn(appEmbed, 'setIFrameHeight'); appEmbed.render(); - appEmbed.setIframeHeightForNonEmbedLiveboard({ + appEmbed.fullHeightController.handleRouteChange({ data: { currentPath }, type: 'Route', }); @@ -1851,6 +1851,7 @@ describe('App embed tests', () => { test('should register event handlers to adjust iframe height', async () => { let embedHeightCallback: any = () => { }; + let embedIframeCenterCallback: any = () => { }; const onSpy = jest.spyOn(AppEmbed.prototype, 'on').mockImplementation((event, callback) => { if (event === EmbedEvent.RouteChange) { callback({ type: EmbedEvent.RouteChange, data: { currentPath: '/answers' } } as any, jest.fn()); @@ -1859,11 +1860,10 @@ describe('App embed tests', () => { embedHeightCallback = callback; } if (event === EmbedEvent.EmbedIframeCenter) { - callback({ type: EmbedEvent.EmbedIframeCenter, data: {} } as any, jest.fn()); + embedIframeCenterCallback = callback; } return null; }); - jest.spyOn(TsEmbed.prototype as any, 'getIframeCenter').mockReturnValue({}); jest.spyOn(TsEmbed.prototype as any, 'setIFrameHeight').mockReturnValue({}); const appEmbed = new AppEmbed(getRootEl(), { ...defaultViewConfig, @@ -1879,13 +1879,27 @@ describe('App embed tests', () => { await appEmbed.render(); embedHeightCallback({ data: '100%' }); + // The app only asks for the iframe center once the iframe exists. + const centerResponder = jest.fn(); + embedIframeCenterCallback( + { type: EmbedEvent.EmbedIframeCenter, data: {} } as any, + centerResponder, + ); + // Verify event handlers were registered await executeAfterWait(() => { expect(onSpy).toHaveBeenCalledWith(EmbedEvent.EmbedHeight, expect.anything()); expect(onSpy).toHaveBeenCalledWith(EmbedEvent.RouteChange, expect.anything()); expect(onSpy).toHaveBeenCalledWith(EmbedEvent.EmbedIframeCenter, expect.anything()); expect(onSpy).toHaveBeenCalledWith(EmbedEvent.RequestVisibleEmbedCoordinates, expect.anything()); + expect(centerResponder).toHaveBeenCalledWith( + expect.objectContaining({ type: EmbedEvent.EmbedIframeCenter }), + ); }, 100); + + // This test replaces AppEmbed.prototype.on; restore it so the mock does + // not leak into the tests that follow. + jest.restoreAllMocks(); }); describe('Navigate to Page API', () => { @@ -2130,7 +2144,6 @@ describe('App embed tests', () => { const onSpy = jest.spyOn(AppEmbed.prototype, 'on').mockImplementation((event, callback) => { return null; }); - jest.spyOn(TsEmbed.prototype as any, 'getIframeCenter').mockReturnValue({}); jest.spyOn(TsEmbed.prototype as any, 'setIFrameHeight').mockReturnValue({}); // Create the AppEmbed instance @@ -2165,10 +2178,6 @@ describe('App embed tests', () => { fullHeight: true, } as AppViewConfig); - expect((appEmbed as any).viewConfig.lazyLoadingForFullHeight).toBe(true); - expect((appEmbed as any).viewConfig.enableScrollableContainerLazyLoading).toBe(true); - expect((appEmbed as any).viewConfig.lazyLoadingMargin).toBe('500px 0px'); - await appEmbed.render(); await executeAfterWait(() => { @@ -2184,11 +2193,14 @@ describe('App embed tests', () => { ...defaultViewConfig, } as AppViewConfig); - expect((appEmbed as any).viewConfig.lazyLoadingForFullHeight).toBeUndefined(); - expect( - (appEmbed as any).viewConfig.enableScrollableContainerLazyLoading, - ).toBeUndefined(); - expect((appEmbed as any).viewConfig.lazyLoadingMargin).toBeUndefined(); + await appEmbed.render(); + + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).not.toContain('isFullHeightPinboard'); + expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled'); + expect(iframeSrc).not.toContain('rootMarginForLazyLoad'); + }, 100); }); test('should not write the defaults back onto the caller view config', async () => { @@ -2210,11 +2222,14 @@ describe('App embed tests', () => { fullHeight: false, } as AppViewConfig); - expect((appEmbed as any).viewConfig.lazyLoadingForFullHeight).toBeUndefined(); - expect( - (appEmbed as any).viewConfig.enableScrollableContainerLazyLoading, - ).toBeUndefined(); - expect((appEmbed as any).viewConfig.lazyLoadingMargin).toBeUndefined(); + await appEmbed.render(); + + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).not.toContain('isFullHeightPinboard'); + expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled'); + expect(iframeSrc).not.toContain('rootMarginForLazyLoad'); + }, 100); }); test('should default lazyLoadingMargin when lazyLoadingForFullHeight is set explicitly', async () => { @@ -2224,14 +2239,14 @@ describe('App embed tests', () => { lazyLoadingForFullHeight: true, } as AppViewConfig); - expect((appEmbed as any).viewConfig.lazyLoadingMargin).toBe( - config.DEFAULT_LAZY_LOADING_MARGIN, - ); - await appEmbed.render(); await executeAfterWait(() => { - expect(getIFrameSrc()).toContain('rootMarginForLazyLoad=500px%200px'); + expect(getIFrameSrc()).toContain( + `rootMarginForLazyLoad=${encodeURIComponent( + config.DEFAULT_LAZY_LOADING_MARGIN, + )}`, + ); }, 100); }); @@ -2455,7 +2470,7 @@ describe('App embed tests', () => { await appEmbed.render(); // Trigger the lazy load data calculation - (appEmbed as any).sendFullHeightLazyLoadData(); + (appEmbed as any).fullHeightController.sendVisibleCoordinates(); expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { top: 0, @@ -2477,7 +2492,7 @@ describe('App embed tests', () => { await appEmbed.render(); // Trigger the lazy load data calculation - (appEmbed as any).sendFullHeightLazyLoadData(); + (appEmbed as any).fullHeightController.sendVisibleCoordinates(); expect(mockTrigger).not.toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { top: 0, @@ -2510,7 +2525,7 @@ describe('App embed tests', () => { await appEmbed.render(); // Trigger the lazy load data calculation - (appEmbed as any).sendFullHeightLazyLoadData(); + (appEmbed as any).fullHeightController.sendVisibleCoordinates(); expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { top: 50, // 50px clipped from top @@ -2618,7 +2633,7 @@ describe('App embed tests', () => { const mockResponder = jest.fn(); // Trigger the handler directly - (appEmbed as any).requestVisibleEmbedCoordinatesHandler({}, mockResponder); + (appEmbed as any).fullHeightController.handleRequestVisibleCoordinates({}, mockResponder); // Verify the responder was called with the correct data expect(mockResponder).toHaveBeenCalledWith({ @@ -2676,7 +2691,7 @@ describe('App embed tests', () => { data: 600, type: EmbedEvent.EmbedHeight, }; - appEmbed.updateIFrameHeight(mockEvent); + appEmbed.fullHeightController.handleEmbedHeight(mockEvent); // Check if the iframe style was updated expect(mockIFrame.style.height).toBe('600px'); @@ -2697,7 +2712,7 @@ describe('App embed tests', () => { data: 0, // This will make it use the default height type: EmbedEvent.EmbedHeight, }; - appEmbed.updateIFrameHeight(mockEvent); + appEmbed.fullHeightController.handleEmbedHeight(mockEvent); // Should use the default height expect(mockIFrame.style.height).toBe('500px'); @@ -2712,7 +2727,7 @@ describe('App Embed Default Height and Minimum Height Handling', () => { fullHeight: true, } as AppViewConfig); await appEmbed.render(); - expect(appEmbed['defaultHeight']).toBe(500); + expect(appEmbed['fullHeightController'].minimumHeight).toBe(500); }); test('should set default height to 700 when default height is provided', async () => { const appEmbed = new AppEmbed(getRootEl(), { @@ -2721,7 +2736,7 @@ describe('App Embed Default Height and Minimum Height Handling', () => { minimumHeight: 700, } as AppViewConfig); await appEmbed.render(); - expect(appEmbed['defaultHeight']).toBe(700); + expect(appEmbed['fullHeightController'].minimumHeight).toBe(700); }); }); @@ -2807,7 +2822,7 @@ describe('AppEmbed uncovered branch tests', () => { }); }); - test('registerLazyLoadEvents should return early when iFrame is not set', () => { + test('lazy load registration should return early when iFrame is not set', () => { const appEmbed = new AppEmbed(getRootEl(), { ...defaultViewConfig, fullHeight: true, @@ -2815,7 +2830,7 @@ describe('AppEmbed uncovered branch tests', () => { } as AppViewConfig); // iFrame is not set (render not called), should not throw expect(() => { - (appEmbed as any).registerLazyLoadEvents(); + (appEmbed as any).fullHeightController.onRender(); }).not.toThrow(); }); }); diff --git a/src/embed/app.ts b/src/embed/app.ts index 8058b5d1..37ec02f1 100644 --- a/src/embed/app.ts +++ b/src/embed/app.ts @@ -9,19 +9,19 @@ */ import { logger } from '../utils/logger'; -import { calculateVisibleElementData, getEffectiveClippingAncestors, getQueryParamString, getScrollableAncestors, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; -import { DEFAULT_LAZY_LOADING_MARGIN } from '../config'; +import { getQueryParamString, isUndefined, setParamIfDefined } from '../utils'; import { Param, DOMSelector, HostEvent, EmbedEvent, - MessagePayload, AllEmbedViewConfig, + FullHeightViewConfig, DefaultAppInitData, VisualizationOverrides, SpotterFileUploadFileTypes, } from '../types'; +import { FullHeightController, resolveLazyLoadingDefaults } from '../full-height'; import { V1Embed } from './ts-embed'; import { SpotterChatViewConfig, SpotterSidebarViewConfig, SpotterQueryMode, SpotterShareConversationConfig, StarterPromptsConfig } from './conversation'; import { buildSpotterSidebarAppInitData, buildSpotterShareConversationAppInitData, buildStarterPromptsAppInitData } from './spotter-utils'; @@ -179,7 +179,7 @@ export interface DiscoveryExperience { * The view configuration for full app embedding. * @group Embed components */ -export interface AppViewConfig extends AllEmbedViewConfig { +export interface AppViewConfig extends AllEmbedViewConfig, FullHeightViewConfig { /** * If true, the top navigation bar within the ThoughtSpot app * is displayed. By default, the navigation bar is hidden. @@ -464,34 +464,6 @@ export interface AppViewConfig extends AllEmbedViewConfig { * ``` */ enableSearchAssist?: boolean; - /** - * If set to true, the Liveboard container dynamically resizes - * according to the height of the Liveboard. - * - * **Note**: Using fullHeight loads all visualizations - * on the Liveboard simultaneously, which results in - * multiple warehouse queries and potentially a - * longer wait for the topmost visualizations to - * display on the screen. Setting fullHeight to - * `false` fetches visualizations incrementally as - * users scroll the page to view the charts and tables. - * - * From SDK 1.52.0, enabling `fullHeight` also turns on - * {@link lazyLoadingForFullHeight} and - * {@link enableScrollableContainerLazyLoading}, so visualizations load as - * they scroll into view. Set either flag to `false` to opt out. - * - * Supported embed types: `AppEmbed` - * @version SDK: 1.21.0 | ThoughtSpot: 9.4.0.cl, 9.4.0-sw - * @example - * ```js - * const embed = new AppEmbed('#tsEmbed', { - * ... // other embed view config - * fullHeight: true, - * }) - * ``` - */ - fullHeight?: boolean; /** * Enables the V2 navigation and modular home page experience. * For more information, @@ -693,79 +665,7 @@ export interface AppViewConfig extends AllEmbedViewConfig { */ isGranularXLSXCSVSchedulesEnabled?: boolean; - /** - * Loads visualizations only as they scroll into the viewport, instead of - * loading the whole full-height Liveboard at once. - * - * From SDK 1.52.0 this is enabled automatically whenever `fullHeight` is - * `true`. On SDK 1.51.0 and earlier it defaulted to `false` and had to be - * set explicitly. Set it to `false` to load every visualization upfront. - * The flag has no effect unless `fullHeight` is enabled. - * - * @type {boolean} - * @version SDK: 1.40.0 | ThoughtSpot: 10.12.0.cl - * @default true when `fullHeight` is enabled, from SDK 1.52.0 - * @example - * ```js - * const embed = new AppEmbed('#embed-container', { - * // ...other options - * fullHeight: true, - * lazyLoadingForFullHeight: true, - * }) - * ``` - */ - lazyLoadingForFullHeight?: boolean; - /** - * Computes the visible region of the embed against its scrollable and - * clipping ancestors, instead of treating the browser window as the only - * viewport, and tracks scroll and resize on those ancestors. - * - * From SDK 1.52.0 this is enabled automatically whenever `fullHeight` is - * `true`. On SDK 1.51.0 and earlier it defaulted to `false` and had to be - * set explicitly. Set it to `false` when the page scrolls with the window - * and the embed has no clipping ancestor, to skip the extra ancestor - * tracking. - * - * @type {boolean} - * @default true when `fullHeight` is enabled, from SDK 1.52.0 - * @hidden - */ - enableScrollableContainerLazyLoading?: boolean; - /** - * How far outside the viewport a visualization starts loading, when - * {@link lazyLoadingForFullHeight} is enabled. - * - * For example, if the margin is set to '10px', - * the visualization will be loaded 10px before its top edge is visible in the - * viewport. - * - * The format is similar to CSS margin, so `'500px 0px'` extends the - * prefetch 500px above and below the viewport and not sideways. Accepted - * units are `px`, `em`, `rem`, `%`, `vh` and `vw`, plus bare `0` and - * `auto`; an invalid value is logged and ignored. - * - * From SDK 1.52.0 this defaults to `'500px 0px'` — roughly one - * visualization ahead of the scroll position, so a chart has usually - * finished loading by the time it scrolls into view. Use a smaller margin - * to cut warehouse queries further, or `'0px'` to load a visualization - * only once it is actually visible. - * - * @type {string} - * @version SDK: 1.40.0 | ThoughtSpot: 10.12.0.cl - * @default '500px 0px' when `fullHeight` is enabled, from SDK 1.52.0 - * @example - * ```js - * const embed = new AppEmbed('#embed-container', { - * // ...other options - * fullHeight: true, - * lazyLoadingForFullHeight: true, - * // Using 0px, the visualization will be only loaded when it's visible in the viewport. - * lazyLoadingMargin: '0px', - * }) - * ``` - */ - lazyLoadingMargin?: string; /** * updatedSpotterChatPrompt : Controls the updated spotter chat prompt. @@ -954,23 +854,6 @@ export interface AppViewConfig extends AllEmbedViewConfig { * @default false */ enableStopAnswerGenerationEmbed?: boolean; - /** - * This is the minimum height (in pixels) for a full-height App. - * Setting this height helps resolve issues with empty Apps and - * other screens navigable from an App. - * - * @version SDK: 1.44.2 | ThoughtSpot: 10.15.0.cl - * @default 500 - * @example - * ```js - * const embed = new AppEmbed('#embed', { - * ... // other app view config - * fullHeight: true, - * minimumHeight: 600, - * }); - * ``` - */ - minimumHeight?: number; /** * To enable the homepage announcement banner. * Controls the visibility of the announcement section @@ -1044,33 +927,24 @@ export interface AppEmbedAppInitData extends DefaultAppInitData { export class AppEmbed extends V1Embed { protected viewConfig: AppViewConfig; - private defaultHeight = 500; - - private lazyLoadScrollContainers: HTMLElement[] = []; - - private lazyLoadResizeObserver: ResizeObserver | undefined; + private readonly fullHeightController?: FullHeightController; constructor(domSelector: DOMSelector, viewConfig: AppViewConfig) { viewConfig.embedComponentType = 'AppEmbed'; super(domSelector, viewConfig); if (this.viewConfig.fullHeight === true) { - if (this.viewConfig.lazyLoadingForFullHeight === undefined) { - this.viewConfig.lazyLoadingForFullHeight = true; - } - if (this.viewConfig.enableScrollableContainerLazyLoading === undefined) { - this.viewConfig.enableScrollableContainerLazyLoading = true; - } - if (this.viewConfig.lazyLoadingMargin === undefined) { - this.viewConfig.lazyLoadingMargin = DEFAULT_LAZY_LOADING_MARGIN; - } - - this.on(EmbedEvent.RouteChange, this.setIframeHeightForNonEmbedLiveboard); - this.on(EmbedEvent.EmbedHeight, this.updateIFrameHeight); - this.on(EmbedEvent.EmbedIframeCenter, this.embedIframeCenter); - this.on( - EmbedEvent.RequestVisibleEmbedCoordinates, - this.requestVisibleEmbedCoordinatesHandler, - ); + Object.assign(this.viewConfig, resolveLazyLoadingDefaults(this.viewConfig)); + this.fullHeightController = new FullHeightController(this.viewConfig, { + getIframe: () => this.iFrame, + setFrameHeight: (height) => this.setIFrameHeight(height), + on: (eventType, callback) => { + this.on(eventType, callback); + }, + trigger: (hostEvent, data) => { + this.trigger(hostEvent, data); + }, + }); + this.fullHeightController.registerEventHandlers(); } } @@ -1103,7 +977,7 @@ export class AppEmbed extends V1Embed { * embedded Liveboard or visualization. */ protected getEmbedParams() { - const params = this.getEmbedParamsObject(); + const params = this.getUrlQueryParamsObject(); return getQueryParamString(params, true); } @@ -1122,7 +996,6 @@ export class AppEmbed extends V1Embed { hideOrgSwitcher, enableSearchAssist, newConnectionsExperience, - fullHeight, dataPanelV2 = true, updatedSpotterExperience, hideLiveboardHeader = false, @@ -1165,7 +1038,6 @@ export class AppEmbed extends V1Embed { enableStopAnswerGenerationEmbed, spotterChatConfig, spotterDataSources, - minimumHeight, isThisPeriodInDateFiltersEnabled, enableHomepageAnnouncement = false, isContinuousLiveboardPDFEnabled, @@ -1270,15 +1142,7 @@ export class AppEmbed extends V1Embed { params[Param.HideNotification] = !!hideNotification; } - if (fullHeight === true) { - params[Param.fullHeight] = true; - if (this.viewConfig.lazyLoadingForFullHeight) { - params[Param.IsLazyLoadingForEmbedEnabled] = true; - if (isValidCssMargin(this.viewConfig.lazyLoadingMargin)) { - params[Param.RootMarginForLazyLoad] = this.viewConfig.lazyLoadingMargin; - } - } - } + this.fullHeightController?.addQueryParams(params); if (tag) { params[Param.Tag] = tag; @@ -1371,8 +1235,6 @@ export class AppEmbed extends V1Embed { params[Param.IsWYSIWYGLiveboardPDFEnabled] = isContinuousLiveboardPDFEnabled; } - this.defaultHeight = minimumHeight || this.defaultHeight; - if (enableLiveboardDataCache !== undefined) { params[Param.EnableLiveboardDataCache] = enableLiveboardDataCache; } @@ -1428,32 +1290,6 @@ export class AppEmbed extends V1Embed { return params; } - private sendFullHeightLazyLoadData = () => { - const data = calculateVisibleElementData( - this.iFrame, - this.viewConfig.enableScrollableContainerLazyLoading, - ); - // this should be fired only if the lazyLoadingForFullHeight and fullHeight are true - if(this.viewConfig.lazyLoadingForFullHeight && this.viewConfig.fullHeight){ - this.trigger(HostEvent.VisibleEmbedCoordinates, data); - } - } - - /** - * This is a handler for the RequestVisibleEmbedCoordinates event. - * It is used to send the visible coordinates data to the host application. - * @param data The event payload - * @param responder The responder function - */ - private requestVisibleEmbedCoordinatesHandler = (data: MessagePayload, responder: any) => { - logger.info('Sending RequestVisibleEmbedCoordinates', data); - const visibleCoordinatesData = calculateVisibleElementData( - this.iFrame, - this.viewConfig.enableScrollableContainerLazyLoading, - ); - responder({ type: EmbedEvent.RequestVisibleEmbedCoordinates, data: visibleCoordinatesData }); - } - /** * Constructs the URL of the ThoughtSpot app page to be rendered. * @param pageId The ID of the page to be embedded. @@ -1469,44 +1305,6 @@ export class AppEmbed extends V1Embed { return url; } - /** - * Set the iframe height as per the computed height received - * from the ThoughtSpot app. - * @param data The event payload - */ - protected updateIFrameHeight = (data: MessagePayload) => { - this.setIFrameHeight(Math.max(data.data, this.defaultHeight)); - this.sendFullHeightLazyLoadData(); - }; - - private embedIframeCenter = (data: MessagePayload, responder: any) => { - const obj = this.getIframeCenter(); - responder({ type: EmbedEvent.EmbedIframeCenter, data: obj }); - }; - - private setIframeHeightForNonEmbedLiveboard = (data: MessagePayload) => { - const { height: frameHeight } = this.viewConfig.frameParams || {}; - - const liveboardRelatedRoutes = [ - '/pinboard/', - '/insights/pinboard/', - '/schedules/', - '/embed/viz/', - '/embed/insights/viz/', - '/liveboard/', - '/insights/liveboard/', - '/tsl-editor/PINBOARD_ANSWER_BOOK/', - '/import-tsl/PINBOARD_ANSWER_BOOK/', - ]; - - if (liveboardRelatedRoutes.some((path) => data.data.currentPath.startsWith(path))) { - // Ignore the height reset of the frame, if the navigation is - // only within the liveboard page. - return; - } - this.setIFrameHeight(frameHeight || this.defaultHeight); - }; - /** * Gets the ThoughtSpot route of the page for a particular page ID. * @param pageId The identifier for a page in the ThoughtSpot app. @@ -1596,53 +1394,11 @@ export class AppEmbed extends V1Embed { */ public destroy() { super.destroy(); - this.unregisterLazyLoadEvents(); + this.fullHeightController?.destroy(); } private postRender() { - this.registerLazyLoadEvents(); - } - - private registerLazyLoadEvents() { - if (!this.iFrame) { - return; - } - if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { - this.unregisterLazyLoadEvents(); - // TODO: Use passive: true, install modernizr to check for passive - window.addEventListener('resize', this.sendFullHeightLazyLoadData); - window.addEventListener('scroll', this.sendFullHeightLazyLoadData, true); - if (!this.viewConfig.enableScrollableContainerLazyLoading) { - return; - } - this.lazyLoadScrollContainers = getScrollableAncestors(this.iFrame); - this.lazyLoadScrollContainers.forEach((scrollContainer) => { - scrollContainer.addEventListener('scroll', this.sendFullHeightLazyLoadData); - }); - if (typeof ResizeObserver !== 'undefined') { - const resizeTargets = new Set([ - this.iFrame.parentElement, - ...getEffectiveClippingAncestors(this.iFrame), - ].filter(Boolean) as HTMLElement[]); - this.lazyLoadResizeObserver = new ResizeObserver(this.sendFullHeightLazyLoadData); - resizeTargets.forEach((resizeTarget) => { - this.lazyLoadResizeObserver.observe(resizeTarget); - }); - } - } - } - - private unregisterLazyLoadEvents() { - if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { - window.removeEventListener('resize', this.sendFullHeightLazyLoadData); - window.removeEventListener('scroll', this.sendFullHeightLazyLoadData, true); - this.lazyLoadResizeObserver?.disconnect(); - this.lazyLoadResizeObserver = undefined; - this.lazyLoadScrollContainers.forEach((scrollContainer) => { - scrollContainer.removeEventListener('scroll', this.sendFullHeightLazyLoadData); - }); - this.lazyLoadScrollContainers = []; - } + this.fullHeightController?.onRender(); } /** diff --git a/src/embed/auto-frame-renderer.ts b/src/embed/auto-frame-renderer.ts index e0730a58..e6506d4c 100644 --- a/src/embed/auto-frame-renderer.ts +++ b/src/embed/auto-frame-renderer.ts @@ -108,7 +108,7 @@ class AutoFrameRenderer extends TsEmbed { * @returns The constructed URL to use for the ThoughtSpot embed iframe. */ private getMCPIframeSrc(sourceSrc: string) { - const queryParams = this.getEmbedParamsObject(); + const queryParams = this.getUrlQueryParamsObject(); const sourceURL = new URL(sourceSrc); const existingQueryParams = sourceURL.searchParams; const existingQueryParamsObject = Object.fromEntries(existingQueryParams); diff --git a/src/embed/bodyless-conversation.ts b/src/embed/bodyless-conversation.ts index 3af6480a..cecfa11f 100644 --- a/src/embed/bodyless-conversation.ts +++ b/src/embed/bodyless-conversation.ts @@ -56,7 +56,7 @@ export class ConversationMessage extends TsEmbed { messageId, } = this.viewConfig; const path = 'conv-assist-answer'; - const queryParams = this.getEmbedParamsObject(); + const queryParams = this.getUrlQueryParamsObject(); let query = ''; const queryParamsString = getQueryParamString(queryParams, true); diff --git a/src/embed/conversation.ts b/src/embed/conversation.ts index c280db14..e890e71e 100644 --- a/src/embed/conversation.ts +++ b/src/embed/conversation.ts @@ -931,7 +931,7 @@ export class SpotterEmbed extends TsEmbed { const path = sharedConversationId ? `insights/conv-assist/s/${encodeURIComponent(sharedConversationId)}` : 'insights/conv-assist'; - const queryParams = this.getEmbedParamsObject(); + const queryParams = this.getUrlQueryParamsObject(); let query = ''; const queryParamsString = getQueryParamString(queryParams, true); diff --git a/src/embed/liveboard.spec.ts b/src/embed/liveboard.spec.ts index 206e56b8..5c39e631 100644 --- a/src/embed/liveboard.spec.ts +++ b/src/embed/liveboard.spec.ts @@ -953,7 +953,7 @@ describe('Liveboard/viz embed tests', () => { const spySetIFrameHeight = jest.spyOn(myObject, 'setIFrameHeight'); myObject.render(); - myObject.setIframeHeightForNonEmbedLiveboard({ + myObject.fullHeightController.handleRouteChange({ data: { currentPath: '/embed/viz/' }, type: 'Route', }); @@ -971,7 +971,7 @@ describe('Liveboard/viz embed tests', () => { const spySetIFrameHeight = jest.spyOn(myObject, 'setIFrameHeight'); myObject.render(); - myObject.setIframeHeightForNonEmbedLiveboard({ + myObject.fullHeightController.handleRouteChange({ data: { currentPath: '/embed/insights/viz/' }, type: 'Route', }); @@ -991,7 +991,7 @@ describe('Liveboard/viz embed tests', () => { .mockImplementation(jest.fn()); myObject.render(); - myObject.setIframeHeightForNonEmbedLiveboard({ + myObject.fullHeightController.handleRouteChange({ data: { currentPath: '/some/other/path/' }, type: 'Route', }); @@ -2059,12 +2059,6 @@ describe('Liveboard/viz embed tests', () => { fullHeight: true, } as LiveboardViewConfig); - expect((liveboardEmbed as any).viewConfig.lazyLoadingForFullHeight).toBe(true); - expect( - (liveboardEmbed as any).viewConfig.enableScrollableContainerLazyLoading, - ).toBe(true); - expect((liveboardEmbed as any).viewConfig.lazyLoadingMargin).toBe('500px 0px'); - await liveboardEmbed.render(); await executeAfterWait(() => { @@ -2081,11 +2075,14 @@ describe('Liveboard/viz embed tests', () => { liveboardId, } as LiveboardViewConfig); - expect((liveboardEmbed as any).viewConfig.lazyLoadingForFullHeight).toBeUndefined(); - expect( - (liveboardEmbed as any).viewConfig.enableScrollableContainerLazyLoading, - ).toBeUndefined(); - expect((liveboardEmbed as any).viewConfig.lazyLoadingMargin).toBeUndefined(); + await liveboardEmbed.render(); + + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).not.toContain('isFullHeightPinboard'); + expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled'); + expect(iframeSrc).not.toContain('rootMarginForLazyLoad'); + }, 100); }); test('should not write the defaults back onto the caller view config', async () => { @@ -2109,11 +2106,14 @@ describe('Liveboard/viz embed tests', () => { fullHeight: false, } as LiveboardViewConfig); - expect((liveboardEmbed as any).viewConfig.lazyLoadingForFullHeight).toBeUndefined(); - expect( - (liveboardEmbed as any).viewConfig.enableScrollableContainerLazyLoading, - ).toBeUndefined(); - expect((liveboardEmbed as any).viewConfig.lazyLoadingMargin).toBeUndefined(); + await liveboardEmbed.render(); + + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).not.toContain('isFullHeightPinboard'); + expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled'); + expect(iframeSrc).not.toContain('rootMarginForLazyLoad'); + }, 100); }); test('should default lazyLoadingMargin when lazyLoadingForFullHeight is set explicitly', async () => { @@ -2124,14 +2124,12 @@ describe('Liveboard/viz embed tests', () => { lazyLoadingForFullHeight: true, } as LiveboardViewConfig); - expect((liveboardEmbed as any).viewConfig.lazyLoadingMargin).toBe( - DEFAULT_LAZY_LOADING_MARGIN, - ); - await liveboardEmbed.render(); await executeAfterWait(() => { - expect(getIFrameSrc()).toContain('rootMarginForLazyLoad=500px%200px'); + expect(getIFrameSrc()).toContain( + `rootMarginForLazyLoad=${encodeURIComponent(DEFAULT_LAZY_LOADING_MARGIN)}`, + ); }, 100); }); @@ -2370,7 +2368,7 @@ describe('Liveboard/viz embed tests', () => { await liveboardEmbed.render(); // Trigger the lazy load data calculation - (liveboardEmbed as any).sendFullHeightLazyLoadData(); + (liveboardEmbed as any).fullHeightController.sendVisibleCoordinates(); expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { top: 0, @@ -2393,7 +2391,7 @@ describe('Liveboard/viz embed tests', () => { await liveboardEmbed.render(); // Trigger the lazy load data calculation - (liveboardEmbed as any).sendFullHeightLazyLoadData(); + (liveboardEmbed as any).fullHeightController.sendVisibleCoordinates(); expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { top: 0, @@ -2426,7 +2424,7 @@ describe('Liveboard/viz embed tests', () => { await liveboardEmbed.render(); // Trigger the lazy load data calculation - (liveboardEmbed as any).sendFullHeightLazyLoadData(); + (liveboardEmbed as any).fullHeightController.sendVisibleCoordinates(); expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { top: 50, @@ -2538,7 +2536,7 @@ describe('Liveboard/viz embed tests', () => { const mockResponder = jest.fn(); // Trigger the handler directly - (liveboardEmbed as any).requestVisibleEmbedCoordinatesHandler({}, mockResponder); + (liveboardEmbed as any).fullHeightController.handleRequestVisibleCoordinates({}, mockResponder); // Verify the responder was called with the correct data expect(mockResponder).toHaveBeenCalledWith({ @@ -2764,7 +2762,7 @@ describe('Liveboard/viz embed tests', () => { minimumHeight: 800, }); await liveboardEmbed.render(); - expect(liveboardEmbed['defaultHeight']).toBe(800); + expect(liveboardEmbed['fullHeightController'].minimumHeight).toBe(800); }); test('should set default height to 700 when default height is provided', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { @@ -2774,7 +2772,7 @@ describe('Liveboard/viz embed tests', () => { defaultHeight: 700, }); await liveboardEmbed.render(); - expect(liveboardEmbed['defaultHeight']).toBe(700); + expect(liveboardEmbed['fullHeightController'].minimumHeight).toBe(700); }); test('should set default height to 800 when minimum height is provided but default height is not', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { @@ -2784,7 +2782,7 @@ describe('Liveboard/viz embed tests', () => { minimumHeight: 800, }); await liveboardEmbed.render(); - expect(liveboardEmbed['defaultHeight']).toBe(800); + expect(liveboardEmbed['fullHeightController'].minimumHeight).toBe(800); }); test('should set default height to 500 when neither default height nor minimum height is provided', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { @@ -2793,7 +2791,7 @@ describe('Liveboard/viz embed tests', () => { fullHeight: true, }); await liveboardEmbed.render(); - expect(liveboardEmbed['defaultHeight']).toBe(500); + expect(liveboardEmbed['fullHeightController'].minimumHeight).toBe(500); }); }); }); diff --git a/src/embed/liveboard.ts b/src/embed/liveboard.ts index ac34ef7a..23d2972a 100644 --- a/src/embed/liveboard.ts +++ b/src/embed/liveboard.ts @@ -12,7 +12,6 @@ import { getPreview } from '../utils/graphql/preview-service'; import { ERROR_MESSAGE } from '../errors'; import { EmbedEvent, - MessagePayload, Param, RuntimeFilter, DOMSelector, @@ -20,13 +19,14 @@ import { SearchLiveboardCommonViewConfig as LiveboardOtherViewConfig, BaseViewConfig, LiveboardAppEmbedViewConfig, + FullHeightViewConfig, ErrorDetailsTypes, EmbedErrorCodes, ContextType, DefaultAppInitData, } from '../types'; -import { calculateVisibleElementData, getEffectiveClippingAncestors, getQueryParamString, getScrollableAncestors, isUndefined, isValidCssMargin, setParamIfDefined } from '../utils'; -import { DEFAULT_LAZY_LOADING_MARGIN } from '../config'; +import { FullHeightController, resolveLazyLoadingDefaults } from '../full-height'; +import { getQueryParamString, isUndefined, setParamIfDefined } from '../utils'; import { getAuthPromise } from './base'; import { TsEmbed, V1Embed } from './ts-embed'; import { addPreviewStylesIfNotPresent } from '../utils/global-styles'; @@ -52,71 +52,11 @@ export interface LiveboardEmbedAppInitData extends DefaultAppInitData { * The configuration for the embedded Liveboard or visualization page view. * @group Embed components */ -export interface LiveboardViewConfig extends BaseViewConfig, LiveboardOtherViewConfig, LiveboardAppEmbedViewConfig { - /** - * If set to true, the embedded object container dynamically resizes - * according to the height of the Liveboard. - * - * **Note**: Using fullHeight loads all visualizations on the - * Liveboard simultaneously, which results in multiple warehouse - * queries and potentially a longer wait for the topmost - * visualizations to display on the screen. - * Setting `fullHeight` to `false` fetches visualizations - * incrementally as users scroll the page to view the charts and tables. - * - * From SDK 1.52.0, enabling `fullHeight` also turns on - * {@link lazyLoadingForFullHeight} and - * {@link enableScrollableContainerLazyLoading}, so visualizations load as - * they scroll into view. Set either flag to `false` to opt out. - * - * - * Supported embed types: `LiveboardEmbed` - * @version SDK: 1.1.0 | ThoughtSpot: ts7.may.cl, 7.2.1 - * @example - * ```js - * const embed = new LiveboardEmbed('#embed', { - * ... // other liveboard view config - * fullHeight: true, - * }); - * ``` - */ - fullHeight?: boolean; - /** - * This is the minimum height (in pixels) for a full-height Liveboard. - * Setting this height helps resolve issues with empty Liveboards and - * other screens navigable from a Liveboard. - * - * Supported embed types: `LiveboardEmbed` - * @version SDK: 1.5.0 | ThoughtSpot: ts7.oct.cl, 7.2.1 - * @deprecated Use `minimumHeight` instead. - * @default 500 - * @example - * ```js - * const embed = new LiveboardEmbed('#embed', { - * ... // other liveboard view config - * fullHeight: true, - * defaultHeight: 600, - * }); - * ``` - */ - defaultHeight?: number; - /** - * This is the minimum height (in pixels) for a full-height Liveboard. - * Setting this height helps resolve issues with empty Liveboards and - * other screens navigable from a Liveboard. - * - * @version SDK: 1.44.2 | ThoughtSpot: 10.15.0.cl - * @default 500 - * @example - * ```js - * const embed = new LiveboardEmbed('#embed', { - * ... // other liveboard view config - * fullHeight: true, - * minimumHeight: 600, - * }); - * ``` - */ - minimumHeight?: number; +export interface LiveboardViewConfig + extends BaseViewConfig, + FullHeightViewConfig, + LiveboardOtherViewConfig, + LiveboardAppEmbedViewConfig { /** * If set to true, the context menu in visualizations will be enabled. * @version SDK: 1.1.0 | ThoughtSpot: 8.1.0.sw @@ -462,77 +402,6 @@ export interface LiveboardViewConfig extends BaseViewConfig, LiveboardOtherViewC * ``` */ isGranularXLSXCSVSchedulesEnabled?: boolean; - /** - * Loads visualizations only as they scroll into the viewport, instead of - * loading the whole full-height Liveboard at once. - * - * From SDK 1.52.0 this is enabled automatically whenever `fullHeight` is - * `true`. On SDK 1.51.0 and earlier it defaulted to `false` and had to be - * set explicitly. Set it to `false` to load every visualization upfront. - * The flag has no effect unless `fullHeight` is enabled. - * - * @type {boolean} - * @version SDK: 1.40.0 | ThoughtSpot: 10.12.0.cl - * @default true when `fullHeight` is enabled, from SDK 1.52.0 - * @example - * ```js - * const embed = new LiveboardEmbed('#embed-container', { - * // ...other options - * fullHeight: true, - * lazyLoadingForFullHeight: true, - * }) - * ``` - */ - lazyLoadingForFullHeight?: boolean; - /** - * Computes the visible region of the embed against its scrollable and - * clipping ancestors, instead of treating the browser window as the only - * viewport, and tracks scroll and resize on those ancestors. - * - * From SDK 1.52.0 this is enabled automatically whenever `fullHeight` is - * `true`. On SDK 1.51.0 and earlier it defaulted to `false` and had to be - * set explicitly. Set it to `false` when the page scrolls with the window - * and the embed has no clipping ancestor, to skip the extra ancestor - * tracking. - * - * @type {boolean} - * @default true when `fullHeight` is enabled, from SDK 1.52.0 - */ - enableScrollableContainerLazyLoading?: boolean; - /** - * How far outside the viewport a visualization starts loading, when - * {@link lazyLoadingForFullHeight} is enabled. - * - * For example, if the margin is set to '10px', - * the visualization will be loaded 10px before its top edge is visible in the - * viewport. - * - * The format is similar to CSS margin, so `'500px 0px'` extends the - * prefetch 500px above and below the viewport and not sideways. Accepted - * units are `px`, `em`, `rem`, `%`, `vh` and `vw`, plus bare `0` and - * `auto`; an invalid value is logged and ignored. - * - * From SDK 1.52.0 this defaults to `'500px 0px'` — roughly one - * visualization ahead of the scroll position, so a chart has usually - * finished loading by the time it scrolls into view. Use a smaller margin - * to cut warehouse queries further, or `'0px'` to load a visualization - * only once it is actually visible. - * - * @type {string} - * @version SDK: 1.40.0 | ThoughtSpot: 10.12.0.cl - * @default '500px 0px' when `fullHeight` is enabled, from SDK 1.52.0 - * @example - * ```js - * const embed = new LiveboardEmbed('#embed-container', { - * // ...other options - * fullHeight: true, - * lazyLoadingForFullHeight: true, - * // Using 0px, the visualization will be only loaded when it's visible in the viewport. - * lazyLoadingMargin: '0px', - * }) - * ``` - */ - lazyLoadingMargin?: string; /** * showSpotterLimitations : show limitation text * of the spotter underneath the chat input. @@ -688,36 +557,28 @@ export interface LiveboardViewConfig extends BaseViewConfig, LiveboardOtherViewC export class LiveboardEmbed extends V1Embed { protected viewConfig: LiveboardViewConfig; - private defaultHeight = 500; - - private lazyLoadScrollContainers: HTMLElement[] = []; - - private lazyLoadResizeObserver: ResizeObserver | undefined; - + private readonly fullHeightController?: FullHeightController; constructor(domSelector: DOMSelector, viewConfig: LiveboardViewConfig) { viewConfig.embedComponentType = 'LiveboardEmbed'; super(domSelector, viewConfig); if (this.viewConfig.fullHeight === true) { if (this.viewConfig.vizId) { - logger.warn('Full height is currently only supported for Liveboard embeds.' + - 'Using full height with vizId might lead to unexpected behavior.'); - } - - if (this.viewConfig.lazyLoadingForFullHeight === undefined) { - this.viewConfig.lazyLoadingForFullHeight = true; - } - if (this.viewConfig.enableScrollableContainerLazyLoading === undefined) { - this.viewConfig.enableScrollableContainerLazyLoading = true; - } - if (this.viewConfig.lazyLoadingMargin === undefined) { - this.viewConfig.lazyLoadingMargin = DEFAULT_LAZY_LOADING_MARGIN; + logger.warn('Full height is currently only supported for Liveboard embeds.' + + 'Using full height with vizId might lead to unexpected behavior.'); } - - this.on(EmbedEvent.RouteChange, this.setIframeHeightForNonEmbedLiveboard); - this.on(EmbedEvent.EmbedHeight, this.updateIFrameHeight); - this.on(EmbedEvent.EmbedIframeCenter, this.embedIframeCenter); - this.on(EmbedEvent.RequestVisibleEmbedCoordinates, this.requestVisibleEmbedCoordinatesHandler); + Object.assign(this.viewConfig, resolveLazyLoadingDefaults(this.viewConfig)); + this.fullHeightController = new FullHeightController(this.viewConfig, { + getIframe: () => this.iFrame, + setFrameHeight: (height) => this.setIFrameHeight(height), + on: (eventType, callback) => { + this.on(eventType, callback); + }, + trigger: (hostEvent, data) => { + this.trigger(hostEvent, data); + }, + }); + this.fullHeightController.registerEventHandlers(); } } @@ -732,7 +593,7 @@ export class LiveboardEmbed extends V1Embed { * embedded Liveboard or visualization. */ protected getEmbedParams() { - const params = this.getEmbedParamsObject(); + const params = this.getUrlQueryParamsObject(); const queryParams = getQueryParamString(params, true); return queryParams; } @@ -742,9 +603,6 @@ export class LiveboardEmbed extends V1Embed { params = this.getBaseQueryParams(params); const { enableVizTransformations, - fullHeight, - defaultHeight, - minimumHeight, visibleVizs, liveboardV2, vizId, @@ -793,16 +651,7 @@ export class LiveboardEmbed extends V1Embed { const preventLiveboardFilterRemoval = this.viewConfig.preventLiveboardFilterRemoval || this.viewConfig.preventPinboardFilterRemoval; - if (fullHeight === true) { - params[Param.fullHeight] = true; - if (this.viewConfig.lazyLoadingForFullHeight) { - params[Param.IsLazyLoadingForEmbedEnabled] = true; - if (isValidCssMargin(this.viewConfig.lazyLoadingMargin)) { - params[Param.RootMarginForLazyLoad] = this.viewConfig.lazyLoadingMargin; - } - } - } - this.defaultHeight = minimumHeight || defaultHeight || this.defaultHeight; + this.fullHeightController?.addQueryParams(params); if (enableVizTransformations !== undefined) { params[Param.EnableVizTransformations] = enableVizTransformations.toString(); } @@ -1017,32 +866,6 @@ export class LiveboardEmbed extends V1Embed { return suffix; } - private sendFullHeightLazyLoadData = () => { - const data = calculateVisibleElementData( - this.iFrame, - this.viewConfig.enableScrollableContainerLazyLoading, - ); - // this should be fired only if the lazyLoadingForFullHeight and fullHeight are true - if(this.viewConfig.lazyLoadingForFullHeight && this.viewConfig.fullHeight){ - this.trigger(HostEvent.VisibleEmbedCoordinates, data); - } - }; - - /** - * This is a handler for the RequestVisibleEmbedCoordinates event. - * It is used to send the visible coordinates data to the host application. - * @param data The event payload - * @param responder The responder function - */ - private requestVisibleEmbedCoordinatesHandler = (data: MessagePayload, responder: any) => { - logger.info('Sending RequestVisibleEmbedCoordinates', data); - const visibleCoordinatesData = calculateVisibleElementData( - this.iFrame, - this.viewConfig.enableScrollableContainerLazyLoading, - ); - responder({ type: EmbedEvent.RequestVisibleEmbedCoordinates, data: visibleCoordinatesData }); - } - /** * Construct the URL of the embedded ThoughtSpot Liveboard or visualization * to be loaded within the iFrame. @@ -1067,44 +890,6 @@ export class LiveboardEmbed extends V1Embed { )}`; } - /** - * Set the iframe height as per the computed height received - * from the ThoughtSpot app. - * @param data The event payload - */ - private updateIFrameHeight = (data: MessagePayload) => { - this.setIFrameHeight(Math.max(data.data, this.defaultHeight)); - this.sendFullHeightLazyLoadData(); - }; - - private embedIframeCenter = (data: MessagePayload, responder: any) => { - const obj = this.getIframeCenter(); - responder({ type: EmbedEvent.EmbedIframeCenter, data: obj }); - }; - - private setIframeHeightForNonEmbedLiveboard = (data: MessagePayload) => { - const { height: frameHeight } = this.viewConfig.frameParams || {}; - - const liveboardRelatedRoutes = [ - '/pinboard/', - '/insights/pinboard/', - '/schedules/', - '/embed/viz/', - '/embed/insights/viz/', - '/liveboard/', - '/insights/liveboard/', - '/tsl-editor/PINBOARD_ANSWER_BOOK/', - '/import-tsl/PINBOARD_ANSWER_BOOK/', - ]; - - if (liveboardRelatedRoutes.some((path) => data.data.currentPath.startsWith(path))) { - // Ignore the height reset of the frame, if the navigation is - // only within the liveboard page. - return; - } - this.setIFrameHeight(frameHeight || this.defaultHeight); - }; - private setActiveTab(data: { tabId: string }) { if (!this.viewConfig.vizId) { const prefixPath = this.iFrame.src.split('#/')[1].split('/tab')[0]; @@ -1221,53 +1006,11 @@ export class LiveboardEmbed extends V1Embed { */ public destroy() { super.destroy(); - this.unregisterLazyLoadEvents(); + this.fullHeightController?.destroy(); } private postRender() { - this.registerLazyLoadEvents(); - } - - private registerLazyLoadEvents() { - if(!this.iFrame) { - return; - } - if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { - this.unregisterLazyLoadEvents(); - // TODO: Use passive: true, install modernizr to check for passive - window.addEventListener('resize', this.sendFullHeightLazyLoadData); - window.addEventListener('scroll', this.sendFullHeightLazyLoadData, true); - if (!this.viewConfig.enableScrollableContainerLazyLoading) { - return; - } - this.lazyLoadScrollContainers = getScrollableAncestors(this.iFrame); - this.lazyLoadScrollContainers.forEach((scrollContainer) => { - scrollContainer.addEventListener('scroll', this.sendFullHeightLazyLoadData); - }); - if (typeof ResizeObserver !== 'undefined') { - const resizeTargets = new Set([ - this.iFrame.parentElement, - ...getEffectiveClippingAncestors(this.iFrame), - ].filter(Boolean) as HTMLElement[]); - this.lazyLoadResizeObserver = new ResizeObserver(this.sendFullHeightLazyLoadData); - resizeTargets.forEach((resizeTarget) => { - this.lazyLoadResizeObserver.observe(resizeTarget); - }); - } - } - } - - private unregisterLazyLoadEvents() { - if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { - window.removeEventListener('resize', this.sendFullHeightLazyLoadData); - window.removeEventListener('scroll', this.sendFullHeightLazyLoadData, true); - this.lazyLoadResizeObserver?.disconnect(); - this.lazyLoadResizeObserver = undefined; - this.lazyLoadScrollContainers.forEach((scrollContainer) => { - scrollContainer.removeEventListener('scroll', this.sendFullHeightLazyLoadData); - }); - this.lazyLoadScrollContainers = []; - } + this.fullHeightController?.onRender(); } /** diff --git a/src/embed/search-bar.tsx b/src/embed/search-bar.tsx index 160d87f5..3607aeef 100644 --- a/src/embed/search-bar.tsx +++ b/src/embed/search-bar.tsx @@ -114,7 +114,10 @@ export class SearchBarEmbed extends TsEmbed { protected embedComponentType = 'SearchBarEmbed'; constructor(domSelector: string, viewConfig: SearchBarViewConfig) { - super(domSelector); + // The view config is passed up so the base constructor can read the + // flags it needs while it wires up the embed, then re-assigned here to + // keep this class's existing view config semantics. + super(domSelector, viewConfig); this.viewConfig = viewConfig; } @@ -163,7 +166,7 @@ export class SearchBarEmbed extends TsEmbed { * @param dataSources A list of data source GUIDs */ private getIFrameSrc() { - const queryParams = this.getEmbedParamsObject(); + const queryParams = this.getUrlQueryParamsObject(); const path = 'search-bar-embed'; let query = ''; diff --git a/src/embed/search.spec.ts b/src/embed/search.spec.ts index 6a953923..a551d8e0 100644 --- a/src/embed/search.spec.ts +++ b/src/embed/search.spec.ts @@ -743,6 +743,21 @@ test('should pass forceTable parameter when forceTable is true', async () => { }); describe('SearchBarEmbed tests', () => { + test('should keep data sources off the URL when sendConfigAsPostMessage is set', async () => { + const searchBarEmbed = new SearchBarEmbed(getRootEl() as any, { + ...defaultViewConfig, + dataSources: ['source-1', 'source-2'], + sendConfigAsPostMessage: true, + } as any); + searchBarEmbed.render(); + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).toContain('hostAppUrl='); + expect(iframeSrc).not.toContain('dataSources'); + expect(iframeSrc).not.toContain('source-1'); + }); + }); + test('should pass dataSources parameter when dataSources array is provided', async () => { const searchBarEmbed = new SearchBarEmbed(getRootEl() as any, { ...defaultViewConfig, diff --git a/src/embed/search.ts b/src/embed/search.ts index 680b42bb..7a2a06e2 100644 --- a/src/embed/search.ts +++ b/src/embed/search.ts @@ -526,7 +526,7 @@ export class SearchEmbed extends TsEmbed { excludeRuntimeParametersfromURL, excludeRuntimeFiltersfromURL, } = this.viewConfig; - const queryParams = this.getEmbedParamsObject(); + const queryParams = this.getUrlQueryParamsObject(); let query = ''; const queryParamsString = getQueryParamString(queryParams, true); if (queryParamsString) { diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 319ae3b6..9987eed3 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -5215,16 +5215,6 @@ describe('Additional Coverage Tests', () => { }); }); - test('should test getIframeCenter calculation', async () => { - const searchEmbed = new SearchEmbed(getRootEl(), defaultViewConfig); - await searchEmbed.render(); - await executeAfterWait(() => { - const center = searchEmbed['getIframeCenter'](); - expect(center).toHaveProperty('iframeCenter'); - expect(center).toHaveProperty('iframeHeight'); - expect(center).toHaveProperty('viewPortHeight'); - }); - }); test('should handle preRender with replaceExistingPreRender=true', async () => { createRootEleForEmbed(); @@ -6199,3 +6189,149 @@ describe('ShowPreRender with UpdateEmbedParams', () => { }); }); }); + +describe('sendConfigAsPostMessage', () => { + const lbConfig = { + liveboardId, + hiddenActions: [Action.Download], + additionalFlags: { internalBlinkFlag: true }, + liveboardV2: true, + }; + + const renderAndGetSrc = async (viewConfig: any) => { + const embed = new LiveboardEmbed(getRootEl(), { ...defaultViewConfig, ...viewConfig }); + await embed.render(); + await waitFor(() => !!getIFrameEl()); + return { embed, src: getIFrameSrc() }; + }; + + const signalFrameReady = () => { + postMessageToParent(getIFrameEl().contentWindow, { + type: EmbedEvent.EmbedListenerReady, + }); + }; + + beforeEach(() => { + document.body.innerHTML = getDocumentBody(); + mockProcessTrigger.mockReset(); + mockProcessTrigger.mockResolvedValue({}); + init({ + thoughtSpotHost, + authType: AuthType.None, + }); + }); + + test('leaves the URL untouched when the flag is not set', async () => { + const { src } = await renderAndGetSrc(lbConfig); + + expect(src).toContain('hideAction='); + expect(src).toContain('internalBlinkFlag=true'); + expect(src).toContain('isPinboardV2Enabled=true'); + }); + + test('keeps only the bootstrap params on the URL when the flag is set', async () => { + const { src } = await renderAndGetSrc({ ...lbConfig, sendConfigAsPostMessage: true }); + + // Boot and auth handshake stays on the URL. + expect(src).toContain('hostAppUrl='); + expect(src).toContain(`authType=${AuthType.None}`); + expect(src).toContain(`sdkVersion=${version}`); + expect(src).toContain('blockNonEmbedFullAppAccess=true'); + expect(src).toContain('viewPortHeight='); + + // View configuration and internal flags do not. + expect(src).not.toContain('hideAction='); + expect(src).not.toContain('internalBlinkFlag'); + expect(src).not.toContain('isPinboardV2Enabled'); + expect(src).not.toContain('enableDataPanelV2'); + }); + + test('keeps the deep-link route on the URL when the flag is set', async () => { + const { src } = await renderAndGetSrc({ ...lbConfig, sendConfigAsPostMessage: true }); + + expect(src).toContain(`/embed/viz/${liveboardId}`); + }); + + test('sends the full config over UpdateEmbedParams once the frame is ready', async () => { + await renderAndGetSrc({ ...lbConfig, sendConfigAsPostMessage: true }); + + signalFrameReady(); + + await executeAfterWait(() => { + expect(mockProcessTrigger).toHaveBeenCalledWith( + expect.any(Object), + HostEvent.UpdateEmbedParams, + expect.any(String), + expect.objectContaining({ + liveboardId, + hiddenActions: [Action.Download], + internalBlinkFlag: true, + [Param.HideActions]: expect.arrayContaining([Action.Download]), + }), + undefined, + ); + }); + }); + + test('sends UpdateEmbedParams exactly once for a plain embed', async () => { + await renderAndGetSrc({ ...lbConfig, sendConfigAsPostMessage: true }); + + signalFrameReady(); + signalFrameReady(); + + await executeAfterWait(() => { + const updateCalls = mockProcessTrigger.mock.calls.filter( + (call) => call[1] === HostEvent.UpdateEmbedParams, + ); + expect(updateCalls).toHaveLength(1); + }); + }); + + test('does not send UpdateEmbedParams when the flag is not set', async () => { + await renderAndGetSrc(lbConfig); + + signalFrameReady(); + + await executeAfterWait(() => { + const updateCalls = mockProcessTrigger.mock.calls.filter( + (call) => call[1] === HostEvent.UpdateEmbedParams, + ); + expect(updateCalls).toHaveLength(0); + }); + }); + + test('does not double-send for a pre-rendered embed that is shown', async () => { + mockMessageChannel(); + (window as any).ResizeObserver = + window.ResizeObserver || + jest.fn().mockImplementation(() => ({ + disconnect: jest.fn(), + observe: jest.fn(), + unobserve: jest.fn(), + })); + + const embed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + ...lbConfig, + preRenderId: 'send-config-post-message', + sendConfigAsPostMessage: true, + }); + await embed.preRender(); + await waitFor(() => !!getIFrameEl()); + + signalFrameReady(); + await executeAfterWait(() => { + expect(embed.isEmbedContainerLoaded).toBe(true); + }); + + mockProcessTrigger.mockClear(); + await embed.showPreRender(); + + await executeAfterWait(() => { + const updateCalls = mockProcessTrigger.mock.calls.filter( + (call) => call[1] === HostEvent.UpdateEmbedParams, + ); + expect(updateCalls).toHaveLength(1); + }); + }); +}); diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 10ed5d53..487f7231 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -23,7 +23,6 @@ import { AnswerService } from '../utils/graphql/answerService/answerService'; import { getEncodedQueryParamsString, getCssDimension, - getOffsetTop, embedEventStatus, setAttributes, getCustomisations, @@ -108,6 +107,40 @@ const TS_EMBED_ID = '_thoughtspot-embed'; const PRERENDER_CONTAINER_ORIGINAL_POSITION_KEY = 'tsEmbedOriginalPosition'; const PRERENDER_WRAPPER_ID_PREFIX = 'tsEmbed-pre-render-wrapper-'; +/** + * Query parameters that stay on the iframe `src` when the embed sets + * `sendConfigAsPostMessage`. These are the parameters the application shell + * needs before it can receive a postMessage at all: the embed marker, the host + * application URL used to validate the message origin, the SDK version, the + * flags that pick the authentication flow, and the boot-time settings that + * would otherwise be applied a frame late (viewport, log level, locale, + * formatting and org). Everything else is delivered over + * `HostEvent.UpdateEmbedParams`. + * @internal + */ +const BOOTSTRAP_URL_PARAMS: ReadonlySet = new Set([ + Param.EmbedApp, + Param.HostAppUrl, + Param.Version, + Param.AuthType, + Param.AutoLogin, + Param.DisableLoginRedirect, + Param.ForceSAMLAutoRedirect, + Param.cookieless, + Param.preAuthCache, + Param.blockNonEmbedFullAppAccess, + Param.OverrideOrgId, + Param.ViewPortHeight, + Param.ViewPortWidth, + Param.ClientLogLevel, + Param.OverrideNativeConsole, + Param.PendoTrackingKey, + Param.NumberFormatLocale, + Param.DateFormatLocale, + Param.CurrencyFormat, + Param.Locale, +]); + /** * The event id map from v2 event names to v1 event id * v1 events are the classic embed events implemented in Blink v1 @@ -205,6 +238,14 @@ export class TsEmbed { */ private shouldEncodeUrlQueryParams = false; + /** + * Should the embed configuration be delivered over postMessage + * (`HostEvent.UpdateEmbedParams`) once the frame is ready, leaving only the + * bootstrap parameters on the iframe `src`. + * @default false + */ + private sendConfigAsPostMessage = false; + private defaultHiddenActions = [Action.ReportError]; private resizeObserver: ResizeObserver; @@ -232,6 +273,7 @@ export class TsEmbed { excludeRuntimeParametersfromURL: true, ...viewConfig, }; + this.sendConfigAsPostMessage = this.viewConfig.sendConfigAsPostMessage ?? false; this.registerAppInit(); uploadMixpanelEvent(MIXPANEL_EVENT.VISUAL_SDK_EMBED_CREATE, { ...viewConfig, @@ -685,6 +727,11 @@ export class TsEmbed { const authInitHandler = this.createEmbedContainerHandler(EmbedEvent.AuthInit); this.on(EmbedEvent.AuthInit, authInitHandler, { start: false }, true); this.on(EmbedEvent.RefreshAuthToken, this.tokenRefresh, { start: false }, true); + if (this.sendConfigAsPostMessage) { + this.executeAfterEmbedContainerLoaded(() => { + this.sendEmbedParamsOverPostMessage(); + }); + } }; /** @@ -911,7 +958,7 @@ export class TsEmbed { } protected getEmbedParams() { - const queryParams = this.getEmbedParamsObject(); + const queryParams = this.getUrlQueryParamsObject(); return getQueryParamString(queryParams); } @@ -920,6 +967,27 @@ export class TsEmbed { return params; } + /** + * The parameters that go on the iframe `src`. + * + * This is the full parameter set, unless the embed sets + * `sendConfigAsPostMessage`, in which case only the bootstrap parameters are + * kept and the rest is delivered over `HostEvent.UpdateEmbedParams` once the + * frame is ready. Every URL builder must go through this method; + * `getEmbedParamsObject()` stays the full set because it also feeds the + * postMessage payload. + * @returns The parameters to encode into the iframe `src`. + */ + protected getUrlQueryParamsObject(): Record { + const queryParams = this.getEmbedParamsObject(); + if (!this.sendConfigAsPostMessage) { + return queryParams; + } + return Object.fromEntries( + Object.entries(queryParams).filter(([key]) => BOOTSTRAP_URL_PARAMS.has(key)), + ); + } + protected getRootIframeSrc() { const query = this.getEmbedParams(); return this.getEmbedBasePath(query); @@ -1458,41 +1526,6 @@ export class TsEmbed { return V1EventMap[eventType] || eventType; } - /** - * Calculates the iframe center for the current visible viewPort - * of iframe using Scroll position of Host App, offsetTop for iframe - * in Host app. ViewPort height of the tab. - * @returns iframe Center in visible viewport, - * Iframe height, - * View port height. - */ - protected getIframeCenter() { - const offsetTopClient = getOffsetTop(this.iFrame); - const scrollTopClient = window.scrollY; - const viewPortHeight = window.innerHeight; - const iframeHeight = this.iFrame.offsetHeight; - const iframeScrolled = scrollTopClient - offsetTopClient; - let iframeVisibleViewPort; - let iframeOffset; - - if (iframeScrolled < 0) { - iframeVisibleViewPort = viewPortHeight - (offsetTopClient - scrollTopClient); - iframeVisibleViewPort = Math.min(iframeHeight, iframeVisibleViewPort); - iframeOffset = 0; - } else { - iframeVisibleViewPort = Math.min(iframeHeight - iframeScrolled, viewPortHeight); - iframeOffset = iframeScrolled; - } - const iframeCenter = iframeOffset + iframeVisibleViewPort / 2; - return { - iframeCenter, - iframeScrolled, - iframeHeight, - viewPortHeight, - iframeVisibleViewPort, - }; - } - /** * Registers an event listener to trigger an alert when the ThoughtSpot app * sends an event of a particular message type to the host application. @@ -2003,24 +2036,36 @@ export class TsEmbed { return this.renderIFrame(prerenderFrameSrc); } + /** + * Sends the full embed configuration to the embedded app over + * `HostEvent.UpdateEmbedParams`. + * + * Used by the pre-render show path, and by the initial load when the embed + * sets `sendConfigAsPostMessage`. The caller is expected to have waited for + * the embed container to be ready. + */ + protected async sendEmbedParamsOverPostMessage(): Promise { + try { + const params = await this.getUpdateEmbedParamsObject(); + await this.trigger(HostEvent.UpdateEmbedParams, params); + } catch (error) { + logger.error(ERROR_MESSAGE.UPDATE_PARAMS_FAILED, error); + this.handleError({ + errorType: ErrorDetailsTypes.API, + message: error?.message || ERROR_MESSAGE.UPDATE_PARAMS_FAILED, + code: EmbedErrorCodes.UPDATE_PARAMS_FAILED, + error: error?.message || error, + }); + } + } + protected beforePrerenderVisible(): void { // We can ignore this as its a bit expensive and the newer customers // have moved on to UpdateEmbedParams supported clusters // this.validatePreRenderViewConfig(this.viewConfig); removed in #517 logger.debug('triggering UpdateEmbedParams', this.viewConfig); - this.executeAfterEmbedContainerLoaded(async () => { - try { - const params = await this.getUpdateEmbedParamsObject(); - this.trigger(HostEvent.UpdateEmbedParams, params); - } catch (error) { - logger.error(ERROR_MESSAGE.UPDATE_PARAMS_FAILED, error); - this.handleError({ - errorType: ErrorDetailsTypes.API, - message: error?.message || ERROR_MESSAGE.UPDATE_PARAMS_FAILED, - code: EmbedErrorCodes.UPDATE_PARAMS_FAILED, - error: error?.message || error, - }); - } + this.executeAfterEmbedContainerLoaded(() => { + this.sendEmbedParamsOverPostMessage(); }); } diff --git a/src/full-height.spec.ts b/src/full-height.spec.ts new file mode 100644 index 00000000..32ccd33f --- /dev/null +++ b/src/full-height.spec.ts @@ -0,0 +1,998 @@ +import { + FullHeightController, FullHeightEmbedHost, resolveLazyLoadingDefaults, +} from './full-height'; +import { + BaseViewConfig, EmbedEvent, FullHeightViewConfig, HostEvent, MessageCallback, Param, +} from './types'; + +type ControllerConfig = FullHeightViewConfig & Pick; +import { logger } from './utils/logger'; +import { DEFAULT_LAZY_LOADING_MARGIN } from './config'; + +describe('resolveLazyLoadingDefaults', () => { + it('turns every lazy-loading setting on when the host app set none', () => { + expect(resolveLazyLoadingDefaults({})).toEqual({ + lazyLoadingForFullHeight: true, + enableScrollableContainerLazyLoading: true, + lazyLoadingMargin: DEFAULT_LAZY_LOADING_MARGIN, + }); + }); + + it('preserves an explicit opt-out', () => { + expect(resolveLazyLoadingDefaults({ + lazyLoadingForFullHeight: false, + enableScrollableContainerLazyLoading: false, + lazyLoadingMargin: '0px', + })).toEqual({ + lazyLoadingForFullHeight: false, + enableScrollableContainerLazyLoading: false, + lazyLoadingMargin: '0px', + }); + }); + + it('defaults only what the host app left unset', () => { + expect(resolveLazyLoadingDefaults({ lazyLoadingMargin: '50px' })).toEqual({ + lazyLoadingForFullHeight: true, + enableScrollableContainerLazyLoading: true, + lazyLoadingMargin: '50px', + }); + }); + + it('does not write to the config it is given', () => { + const viewConfig: FullHeightViewConfig = { fullHeight: true }; + resolveLazyLoadingDefaults(viewConfig); + expect(viewConfig).toEqual({ fullHeight: true }); + }); +}); + +describe('FullHeightController', () => { + let iFrame: HTMLIFrameElement; + let host: FullHeightEmbedHost; + let handlers: Map; + + const originalResizeObserver = (window as any).ResizeObserver; + const originalScrollY = window.scrollY; + const originalInnerHeight = window.innerHeight; + const originalInnerWidth = window.innerWidth; + + /** + * A rect that only carries the edges the visibility math reads. + */ + const rectOf = (top: number, bottom: number, left = 0, right = 500) => ({ + top, + bottom, + left, + right, + width: right - left, + height: bottom - top, + } as DOMRect); + + const setViewport = (scrollY: number, innerHeight: number, innerWidth = 1024) => { + Object.defineProperty(window, 'scrollY', { value: scrollY, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: innerHeight, configurable: true }); + Object.defineProperty(window, 'innerWidth', { value: innerWidth, configurable: true }); + }; + + /** + * Mirrors what the embed does in its constructor: resolve the lazy-loading + * defaults and hand the controller the result. Returns a new object so the + * caller's config is left alone, letting tests assert on both. + */ + const withDefaults = (viewConfig: ControllerConfig): ControllerConfig => ( + viewConfig.fullHeight === true + ? { ...viewConfig, ...resolveLazyLoadingDefaults(viewConfig) } + : viewConfig + ); + + const createControllerFor = (viewConfig: ControllerConfig) => createController(viewConfig); + + const createController = ( + viewConfig: ControllerConfig, + options: { iframe?: HTMLIFrameElement } = {}, + ) => { + handlers = new Map(); + iFrame = document.createElement('iframe'); + document.body.appendChild(iFrame); + const hostIframe = 'iframe' in options ? options.iframe : iFrame; + host = { + getIframe: () => hostIframe, + setFrameHeight: jest.fn(), + on: (eventType, callback) => { + handlers.set(eventType, callback); + }, + trigger: jest.fn(), + }; + const controller = new FullHeightController(withDefaults(viewConfig), host); + controller.registerEventHandlers(); + return controller; + }; + + /** + * The query params a freshly built controller contributes. The defaults are + * applied to the controller's private copy of the view config, so the + * params are how the host app observes them. + */ + const queryParamsFor = (viewConfig: ControllerConfig) => { + const params: any = {}; + createController(viewConfig).addQueryParams(params); + return params; + }; + + /** + * Puts the embed inside a scrollable container and hands back the spies the + * container assertions need. The caller drives `onRender`, so tests can + * assert what happens before it too. + */ + const mountInScrollContainer = ( + viewConfig: ControllerConfig, + options: { withResizeObserver?: boolean } = {}, + ) => { + const observe = jest.fn(); + const disconnect = jest.fn(); + let resizeCallback: () => void; + const resizeObserverCtor = jest.fn((callback: () => void) => { + resizeCallback = callback; + return { observe, disconnect }; + }); + if (options.withResizeObserver === false) { + delete (window as any).ResizeObserver; + } else { + (window as any).ResizeObserver = resizeObserverCtor; + } + + const scrollContainer = document.createElement('div'); + scrollContainer.style.overflow = 'auto'; + const addContainerListener = jest.spyOn(scrollContainer, 'addEventListener'); + const removeContainerListener = jest.spyOn(scrollContainer, 'removeEventListener'); + + const controller = createController(viewConfig); + scrollContainer.appendChild(iFrame); + document.body.appendChild(scrollContainer); + + return { + controller, + scrollContainer, + addContainerListener, + removeContainerListener, + observe, + disconnect, + resizeObserverCtor, + fireResizeObserver: () => resizeCallback(), + }; + }; + + const visibleCoordinates = () => { + const responder = jest.fn(); + handlers.get(EmbedEvent.RequestVisibleEmbedCoordinates)({} as any, responder); + return responder.mock.calls[0][0].data; + }; + + afterEach(() => { + document.body.innerHTML = ''; + (window as any).ResizeObserver = originalResizeObserver; + setViewport(originalScrollY, originalInnerHeight, originalInnerWidth); + jest.restoreAllMocks(); + }); + + describe('registerEventHandlers', () => { + it('registers every full-height handler when fullHeight is enabled', () => { + createController({ fullHeight: true }); + expect([...handlers.keys()]).toEqual([ + EmbedEvent.RouteChange, + EmbedEvent.EmbedHeight, + EmbedEvent.EmbedIframeCenter, + EmbedEvent.RequestVisibleEmbedCoordinates, + ]); + }); + + it('registers nothing when fullHeight is not enabled', () => { + createController({}); + expect(handlers.size).toBe(0); + }); + + it('registers nothing when fullHeight is explicitly false', () => { + // Lazy loading on its own must not switch the feature on. + createController({ fullHeight: false, lazyLoadingForFullHeight: true }); + expect(handlers.size).toBe(0); + }); + }); + + describe('lazy loading defaults', () => { + it('turns lazy loading on for a full-height embed', () => { + expect(queryParamsFor({ fullHeight: true })).toEqual({ + [Param.fullHeight]: true, + [Param.IsLazyLoadingForEmbedEnabled]: true, + [Param.RootMarginForLazyLoad]: DEFAULT_LAZY_LOADING_MARGIN, + }); + }); + + it('tracks the scrollable containers without the host app opting in', () => { + const { controller, addContainerListener, observe } = mountInScrollContainer({ + fullHeight: true, + }); + controller.onRender(); + expect(addContainerListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(observe).toHaveBeenCalled(); + controller.destroy(); + }); + + it('leaves the view config the host app passed in untouched', () => { + // Defaulting happens in the embed; the controller never writes to + // the config it is handed. Built without `withDefaults` so the + // controller really does receive the caller's own object. + const viewConfig: ControllerConfig = { fullHeight: true }; + const controller = new FullHeightController(viewConfig, { + getIframe: () => iFrame, + setFrameHeight: jest.fn(), + on: jest.fn(), + trigger: jest.fn(), + }); + controller.registerEventHandlers(); + controller.addQueryParams({}); + controller.onRender(); + controller.destroy(); + expect(viewConfig).toEqual({ fullHeight: true }); + }); + + it('leaves an explicit opt-out alone', () => { + expect(queryParamsFor({ + fullHeight: true, + lazyLoadingForFullHeight: false, + lazyLoadingMargin: '0px', + })).toEqual({ [Param.fullHeight]: true }); + }); + + it('honours an explicit container opt-out', () => { + const { controller, addContainerListener, resizeObserverCtor } = mountInScrollContainer( + { fullHeight: true, enableScrollableContainerLazyLoading: false }, + ); + controller.onRender(); + expect(addContainerListener).not.toHaveBeenCalled(); + expect(resizeObserverCtor).not.toHaveBeenCalled(); + }); + + it('defaults only the values the host app left unset', () => { + // Lazy loading defaults on, while the supplied margin survives. + const { controller, addContainerListener } = mountInScrollContainer({ + fullHeight: true, + enableScrollableContainerLazyLoading: false, + lazyLoadingMargin: '50px', + }); + const params: any = {}; + controller.addQueryParams(params); + expect(params).toEqual({ + [Param.fullHeight]: true, + [Param.IsLazyLoadingForEmbedEnabled]: true, + [Param.RootMarginForLazyLoad]: '50px', + }); + + controller.onRender(); + expect(addContainerListener).not.toHaveBeenCalled(); + }); + + it('defaults nothing when fullHeight is not enabled', () => { + const viewConfig: ControllerConfig = {}; + const controller = createControllerFor(viewConfig); + expect(viewConfig).toEqual({}); + + const add = jest.spyOn(window, 'addEventListener'); + controller.onRender(); + expect(add).not.toHaveBeenCalled(); + }); + }); + + describe('minimumHeight', () => { + it('falls back to 500 when neither height is configured', () => { + expect(createController({ fullHeight: true }).minimumHeight).toBe(500); + }); + + it('prefers minimumHeight over the deprecated defaultHeight', () => { + const controller = createController({ + fullHeight: true, + defaultHeight: 700, + minimumHeight: 800, + }); + expect(controller.minimumHeight).toBe(800); + }); + + it('honours the deprecated defaultHeight when minimumHeight is absent', () => { + const controller = createController({ fullHeight: true, defaultHeight: 700 }); + expect(controller.minimumHeight).toBe(700); + }); + + it('treats a zero minimumHeight as unset and honours defaultHeight', () => { + const controller = createController({ + fullHeight: true, + minimumHeight: 0, + defaultHeight: 700, + }); + expect(controller.minimumHeight).toBe(700); + }); + + it('falls back to 500 when both heights are zero', () => { + const controller = createController({ + fullHeight: true, + minimumHeight: 0, + defaultHeight: 0, + }); + expect(controller.minimumHeight).toBe(500); + }); + + it('is available even when fullHeight is off', () => { + expect(createController({ minimumHeight: 800 }).minimumHeight).toBe(800); + }); + }); + + describe('addQueryParams', () => { + it('adds no params when fullHeight is not enabled', () => { + expect(queryParamsFor({ lazyLoadingForFullHeight: true })).toEqual({}); + }); + + it('adds only the full height param when lazy loading is off', () => { + expect(queryParamsFor({ + fullHeight: true, + lazyLoadingForFullHeight: false, + })).toEqual({ [Param.fullHeight]: true }); + }); + + it('adds the lazy loading params, including a valid margin', () => { + expect(queryParamsFor({ + fullHeight: true, + lazyLoadingForFullHeight: true, + lazyLoadingMargin: '100px 0px', + })).toEqual({ + [Param.fullHeight]: true, + [Param.IsLazyLoadingForEmbedEnabled]: true, + [Param.RootMarginForLazyLoad]: '100px 0px', + }); + }); + + it('accepts a four sided margin', () => { + expect(queryParamsFor({ + fullHeight: true, + lazyLoadingMargin: '10px 20px 30px 40px', + })[Param.RootMarginForLazyLoad]).toBe('10px 20px 30px 40px'); + }); + + it('accepts a unitless zero margin', () => { + expect(queryParamsFor({ + fullHeight: true, + lazyLoadingMargin: '0', + })[Param.RootMarginForLazyLoad]).toBe('0'); + }); + + it('keeps the params the embed has already collected', () => { + const params: any = { existing: 'value' }; + createController({ fullHeight: true }).addQueryParams(params); + expect(params.existing).toBe('value'); + }); + + it('produces the same params when called for a second render', () => { + const controller = createController({ fullHeight: true }); + const first: any = {}; + const second: any = {}; + controller.addQueryParams(first); + controller.addQueryParams(second); + expect(second).toEqual(first); + }); + + it('drops an invalid lazy loading margin', () => { + // An invalid margin is reported to the developer, not sent on. + const loggerError = jest.spyOn(logger, 'error').mockImplementation(jest.fn()); + const params = queryParamsFor({ + fullHeight: true, + lazyLoadingForFullHeight: true, + lazyLoadingMargin: 'not-a-margin', + }); + expect(params[Param.RootMarginForLazyLoad]).toBeUndefined(); + expect(loggerError).toHaveBeenCalled(); + }); + + it('drops a margin with more than four sides', () => { + const loggerError = jest.spyOn(logger, 'error').mockImplementation(jest.fn()); + const params = queryParamsFor({ + fullHeight: true, + lazyLoadingMargin: '1px 2px 3px 4px 5px', + }); + expect(params[Param.RootMarginForLazyLoad]).toBeUndefined(); + expect(loggerError).toHaveBeenCalled(); + }); + + it('drops a margin that is not a string', () => { + const loggerError = jest.spyOn(logger, 'error').mockImplementation(jest.fn()); + const params = queryParamsFor({ + fullHeight: true, + lazyLoadingMargin: 100 as any, + }); + expect(params[Param.RootMarginForLazyLoad]).toBeUndefined(); + expect(loggerError).toHaveBeenCalled(); + }); + + it('drops an empty lazy loading margin but keeps the other params', () => { + const loggerError = jest.spyOn(logger, 'error').mockImplementation(jest.fn()); + const params = queryParamsFor({ + fullHeight: true, + lazyLoadingForFullHeight: true, + lazyLoadingMargin: '', + }); + expect(params).toEqual({ + [Param.fullHeight]: true, + [Param.IsLazyLoadingForEmbedEnabled]: true, + }); + expect(loggerError).toHaveBeenCalled(); + }); + }); + + describe('EmbedHeight', () => { + const embedHeight = (data: unknown) => ({ data } as any); + + it('never sizes the frame below the configured minimum', () => { + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(300)); + expect(host.setFrameHeight).toHaveBeenCalledWith(800); + }); + + it('uses the height reported by the app when it clears the minimum', () => { + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(1200)); + expect(host.setFrameHeight).toHaveBeenCalledWith(1200); + }); + + it('never sizes the frame below the 500 default floor', () => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(100)); + expect(host.setFrameHeight).toHaveBeenCalledWith(500); + }); + + it('clamps a negative height to the minimum', () => { + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(-50)); + expect(host.setFrameHeight).toHaveBeenCalledWith(800); + }); + + it('clamps a zero height to the minimum', () => { + // Zero is a height the app really reported, not a missing one. + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(0)); + expect(host.setFrameHeight).toHaveBeenCalledWith(800); + }); + + it('keeps a fractional height that clears the minimum', () => { + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(1200.5)); + expect(host.setFrameHeight).toHaveBeenCalledWith(1200.5); + }); + + it('accepts a numeric height sent as a string', () => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight('1200')); + expect(host.setFrameHeight).toHaveBeenCalledWith(1200); + }); + + it('leaves the height alone when the app reports a non-numeric height', () => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight('tall')); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); + + it('leaves the height alone when the payload carries no height', () => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.EmbedHeight)({} as any); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); + + it('survives a missing payload', () => { + createController({ fullHeight: true }); + expect(() => handlers.get(EmbedEvent.EmbedHeight)(undefined as any)).not.toThrow(); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); + + it('still pushes the visible coordinates for an unusable height', () => { + createController({ fullHeight: true, lazyLoadingForFullHeight: true }); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight('tall')); + expect(host.trigger).toHaveBeenCalledWith( + HostEvent.VisibleEmbedCoordinates, + expect.objectContaining({ top: expect.any(Number) }), + ); + }); + + it('pushes the visible coordinates only when lazy loading is on', () => { + createController({ fullHeight: true, lazyLoadingForFullHeight: false }); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(1200)); + expect(host.trigger).not.toHaveBeenCalled(); + + createController({ fullHeight: true, lazyLoadingForFullHeight: true }); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(1200)); + expect(host.trigger).toHaveBeenCalledWith( + HostEvent.VisibleEmbedCoordinates, + expect.objectContaining({ top: expect.any(Number) }), + ); + }); + + it('pushes no coordinates when the iframe is not there yet', () => { + // There is nothing to measure, so the app is left alone. + createController({ fullHeight: true }, { iframe: null }); + handlers.get(EmbedEvent.EmbedHeight)(embedHeight(1200)); + expect(host.setFrameHeight).toHaveBeenCalledWith(1200); + expect(host.trigger).not.toHaveBeenCalled(); + }); + }); + + describe('RouteChange', () => { + const routeChange = (currentPath: string) => ({ data: { currentPath } } as any); + + it('leaves the height alone while navigating within a Liveboard', () => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/embed/viz/abc')); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); + + it.each([ + '/pinboard/abc', + '/insights/pinboard/abc', + '/schedules/abc', + '/embed/viz/abc', + '/embed/insights/viz/abc', + '/liveboard/abc', + '/insights/liveboard/abc', + '/tsl-editor/PINBOARD_ANSWER_BOOK/abc', + '/import-tsl/PINBOARD_ANSWER_BOOK/abc', + ])('leaves the height alone on the Liveboard route %s', (currentPath) => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.RouteChange)(routeChange(currentPath)); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); + + it('leaves the height alone on a bare Liveboard route', () => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/liveboard/')); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); + + it('resets to frameParams.height when leaving the Liveboard routes', () => { + createController({ fullHeight: true, frameParams: { height: 640 } }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/some/other/path/')); + expect(host.setFrameHeight).toHaveBeenCalledWith(640); + }); + + it('passes a non-numeric frameParams.height through unchanged', () => { + createController({ fullHeight: true, frameParams: { height: '100%' } }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/some/other/path/')); + expect(host.setFrameHeight).toHaveBeenCalledWith('100%'); + }); + + it('resets to the minimum height when frameParams has no height', () => { + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/some/other/path/')); + expect(host.setFrameHeight).toHaveBeenCalledWith(800); + }); + + it('resets when a Liveboard route only appears part way into the path', () => { + // The routes are matched as prefixes, not as substrings. + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/app/embed/viz/abc')); + expect(host.setFrameHeight).toHaveBeenCalledWith(800); + }); + + it('resets on a Liveboard route missing its trailing slash', () => { + createController({ fullHeight: true, minimumHeight: 800 }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/liveboard')); + expect(host.setFrameHeight).toHaveBeenCalledWith(800); + }); + + it('leaves the height alone when the payload carries no path', () => { + createController({ fullHeight: true }); + handlers.get(EmbedEvent.RouteChange)({ data: {} } as any); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); + + it('survives a missing payload', () => { + createController({ fullHeight: true }); + expect(() => handlers.get(EmbedEvent.RouteChange)(undefined as any)).not.toThrow(); + expect(host.setFrameHeight).not.toHaveBeenCalled(); + }); + + it('does not push coordinates on a route change', () => { + // Only a height report and the viewport listeners do that. + createController({ fullHeight: true }); + handlers.get(EmbedEvent.RouteChange)(routeChange('/some/other/path/')); + expect(host.trigger).not.toHaveBeenCalled(); + }); + }); + + describe('coordinate requests', () => { + it('responds to RequestVisibleEmbedCoordinates with the visible region', () => { + createController({ fullHeight: true }); + const responder = jest.fn(); + handlers.get(EmbedEvent.RequestVisibleEmbedCoordinates)({} as any, responder); + expect(responder).toHaveBeenCalledWith({ + type: EmbedEvent.RequestVisibleEmbedCoordinates, + data: expect.objectContaining({ top: expect.any(Number) }), + }); + }); + + it('responds to EmbedIframeCenter with the center of the visible region', () => { + createController({ fullHeight: true }); + const responder = jest.fn(); + handlers.get(EmbedEvent.EmbedIframeCenter)({} as any, responder); + expect(responder).toHaveBeenCalledWith({ + type: EmbedEvent.EmbedIframeCenter, + data: expect.objectContaining({ iframeCenter: expect.any(Number) }), + }); + }); + + it('measures the center against the viewport for an unscrolled page', () => { + setViewport(0, 768); + createController({ fullHeight: true }); + Object.defineProperty(iFrame, 'offsetHeight', { value: 1000, configurable: true }); + jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(100, 1100)); + + const responder = jest.fn(); + handlers.get(EmbedEvent.EmbedIframeCenter)({} as any, responder); + expect(responder.mock.calls[0][0].data).toEqual({ + iframeCenter: 334, + iframeScrolled: -100, + iframeHeight: 1000, + viewPortHeight: 768, + iframeVisibleViewPort: 668, + }); + }); + + it('measures the center against the viewport for a scrolled page', () => { + setViewport(500, 600); + createController({ fullHeight: true }); + Object.defineProperty(iFrame, 'offsetHeight', { value: 2000, configurable: true }); + // The element starts at the page top, so it is 500px scrolled. + jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(-500, 1500)); + + const responder = jest.fn(); + handlers.get(EmbedEvent.EmbedIframeCenter)({} as any, responder); + expect(responder.mock.calls[0][0].data).toEqual({ + iframeCenter: 800, + iframeScrolled: 500, + iframeHeight: 2000, + viewPortHeight: 600, + iframeVisibleViewPort: 600, + }); + }); + + it('logs the RequestVisibleEmbedCoordinates request', () => { + const loggerInfo = jest.spyOn(logger, 'info').mockImplementation(jest.fn()); + createController({ fullHeight: true }); + const payload = { type: EmbedEvent.RequestVisibleEmbedCoordinates } as any; + handlers.get(EmbedEvent.RequestVisibleEmbedCoordinates)(payload, jest.fn()); + expect(loggerInfo).toHaveBeenCalledWith( + 'Sending RequestVisibleEmbedCoordinates', + payload, + ); + }); + + it('responds with null coordinates when the iframe is not there yet', () => { + createController({ fullHeight: true }, { iframe: null }); + const responder = jest.fn(); + handlers.get(EmbedEvent.RequestVisibleEmbedCoordinates)({} as any, responder); + expect(responder).toHaveBeenCalledWith({ + type: EmbedEvent.RequestVisibleEmbedCoordinates, + data: null, + }); + }); + + it('stays silent on EmbedIframeCenter when the iframe is not there yet', () => { + createController({ fullHeight: true }, { iframe: null }); + const responder = jest.fn(); + handlers.get(EmbedEvent.EmbedIframeCenter)({} as any, responder); + expect(responder).not.toHaveBeenCalled(); + }); + + it('survives an app that asks for coordinates without a responder', () => { + createController({ fullHeight: true }); + expect(() => { + handlers.get(EmbedEvent.RequestVisibleEmbedCoordinates)({} as any); + handlers.get(EmbedEvent.EmbedIframeCenter)({} as any); + }).not.toThrow(); + }); + + it('reports the region left uncovered when the embed is off screen', () => { + setViewport(0, 768); + createController({ fullHeight: true }); + jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(900, 1400)); + expect(visibleCoordinates()).toEqual({ + top: 0, height: 0, left: 0, width: 500, + }); + }); + + it('clips the visible region to the containers when container lazy loading is on', () => { + setViewport(0, 768); + const clippingContainer = document.createElement('div'); + clippingContainer.style.overflow = 'hidden'; + createController({ + fullHeight: true, + enableScrollableContainerLazyLoading: true, + }); + clippingContainer.appendChild(iFrame); + document.body.appendChild(clippingContainer); + jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(-100, 400)); + jest.spyOn(clippingContainer, 'getBoundingClientRect') + .mockReturnValue(rectOf(50, 300)); + + expect(visibleCoordinates()).toEqual({ + top: 150, height: 250, left: 0, width: 500, + }); + }); + + it('ignores the containers when container lazy loading is off', () => { + setViewport(0, 768); + const clippingContainer = document.createElement('div'); + clippingContainer.style.overflow = 'hidden'; + createController({ + fullHeight: true, + enableScrollableContainerLazyLoading: false, + }); + clippingContainer.appendChild(iFrame); + document.body.appendChild(clippingContainer); + jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(-100, 400)); + jest.spyOn(clippingContainer, 'getBoundingClientRect') + .mockReturnValue(rectOf(50, 300)); + + expect(visibleCoordinates()).toEqual({ + top: 100, height: 400, left: 0, width: 500, + }); + }); + + it('clips the visible region horizontally too', () => { + setViewport(0, 768, 1024); + const clippingContainer = document.createElement('div'); + clippingContainer.style.overflow = 'hidden'; + createController({ fullHeight: true }); + clippingContainer.appendChild(iFrame); + document.body.appendChild(clippingContainer); + jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(0, 400, -60, 600)); + jest.spyOn(clippingContainer, 'getBoundingClientRect') + .mockReturnValue(rectOf(0, 400, 40, 500)); + + expect(visibleCoordinates()).toEqual({ + top: 0, height: 400, left: 100, width: 460, + }); + }); + }); + + describe('lazy load listeners', () => { + it('attaches window listeners on render and removes them on destroy', () => { + const add = jest.spyOn(window, 'addEventListener'); + const remove = jest.spyOn(window, 'removeEventListener'); + const controller = createController({ + fullHeight: true, + lazyLoadingForFullHeight: true, + }); + + controller.onRender(); + expect(add).toHaveBeenCalledWith('resize', expect.any(Function)); + expect(add).toHaveBeenCalledWith('scroll', expect.any(Function), true); + + controller.destroy(); + expect(remove).toHaveBeenCalledWith('resize', expect.any(Function)); + expect(remove).toHaveBeenCalledWith('scroll', expect.any(Function), true); + }); + + it('attaches nothing when lazy loading is off', () => { + const add = jest.spyOn(window, 'addEventListener'); + createController({ + fullHeight: true, + lazyLoadingForFullHeight: false, + }).onRender(); + expect(add).not.toHaveBeenCalled(); + }); + + it('attaches nothing when fullHeight is off', () => { + const add = jest.spyOn(window, 'addEventListener'); + createController({ lazyLoadingForFullHeight: true }).onRender(); + expect(add).not.toHaveBeenCalled(); + }); + + it('attaches nothing when the iframe has not rendered yet', () => { + const add = jest.spyOn(window, 'addEventListener'); + createController({ fullHeight: true }, { iframe: null }).onRender(); + expect(add).not.toHaveBeenCalled(); + }); + + it('removes nothing on destroy when lazy loading is off', () => { + const remove = jest.spyOn(window, 'removeEventListener'); + createController({ + fullHeight: true, + lazyLoadingForFullHeight: false, + }).destroy(); + expect(remove).not.toHaveBeenCalled(); + }); + + it('pushes the visible coordinates on a window scroll and resize', () => { + const controller = createController({ fullHeight: true }); + controller.onRender(); + + window.dispatchEvent(new Event('scroll')); + expect(host.trigger).toHaveBeenCalledWith( + HostEvent.VisibleEmbedCoordinates, + expect.objectContaining({ top: expect.any(Number) }), + ); + + (host.trigger as jest.Mock).mockClear(); + window.dispatchEvent(new Event('resize')); + expect(host.trigger).toHaveBeenCalledTimes(1); + + controller.destroy(); + (host.trigger as jest.Mock).mockClear(); + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new Event('resize')); + expect(host.trigger).not.toHaveBeenCalled(); + }); + + it('pushes the visible coordinates for a scroll inside a nested element', () => { + // The window scroll listener is registered in the capture phase, so + // a scroll on an inner element reaches it too. + const controller = createController({ fullHeight: true }); + controller.onRender(); + iFrame.dispatchEvent(new Event('scroll', { bubbles: false })); + expect(host.trigger).toHaveBeenCalledWith( + HostEvent.VisibleEmbedCoordinates, + expect.objectContaining({ top: expect.any(Number) }), + ); + controller.destroy(); + }); + + it('picks the listeners back up when the embed re-renders after destroy', () => { + const controller = createController({ fullHeight: true }); + controller.onRender(); + controller.destroy(); + + controller.onRender(); + window.dispatchEvent(new Event('scroll')); + expect(host.trigger).toHaveBeenCalledWith( + HostEvent.VisibleEmbedCoordinates, + expect.objectContaining({ top: expect.any(Number) }), + ); + controller.destroy(); + }); + + it('does not stack duplicate window listeners across renders', () => { + const add = jest.spyOn(window, 'addEventListener'); + const controller = createController({ fullHeight: true }); + + controller.onRender(); + controller.onRender(); + const scrollHandlers = add.mock.calls + .filter(([eventType]) => eventType === 'scroll') + .map(([, handler]) => handler); + // The same reference is re-added, so the browser keeps a + // single listener. + expect(scrollHandlers).toHaveLength(2); + expect(scrollHandlers[0]).toBe(scrollHandlers[1]); + + controller.destroy(); + (host.trigger as jest.Mock).mockClear(); + window.dispatchEvent(new Event('scroll')); + expect(host.trigger).not.toHaveBeenCalled(); + }); + + it('observes the scrollable ancestors when container lazy loading is on', () => { + const { + controller, addContainerListener, removeContainerListener, observe, disconnect, + } = mountInScrollContainer({ + fullHeight: true, + lazyLoadingForFullHeight: true, + enableScrollableContainerLazyLoading: true, + }); + + controller.onRender(); + expect(addContainerListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(observe).toHaveBeenCalled(); + + controller.destroy(); + expect(removeContainerListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(disconnect).toHaveBeenCalled(); + }); + + it('pushes the visible coordinates on a container scroll', () => { + const { controller, scrollContainer } = mountInScrollContainer({ fullHeight: true }); + controller.onRender(); + + scrollContainer.dispatchEvent(new Event('scroll')); + expect(host.trigger).toHaveBeenCalledWith( + HostEvent.VisibleEmbedCoordinates, + expect.objectContaining({ top: expect.any(Number) }), + ); + + controller.destroy(); + (host.trigger as jest.Mock).mockClear(); + scrollContainer.dispatchEvent(new Event('scroll')); + expect(host.trigger).not.toHaveBeenCalled(); + }); + + it('pushes the visible coordinates when an observed container resizes', () => { + const { controller, fireResizeObserver } = mountInScrollContainer({ fullHeight: true }); + controller.onRender(); + + fireResizeObserver(); + expect(host.trigger).toHaveBeenCalledWith( + HostEvent.VisibleEmbedCoordinates, + expect.objectContaining({ top: expect.any(Number) }), + ); + controller.destroy(); + }); + + it('observes the iframe parent even without a scrollable ancestor', () => { + const observe = jest.fn(); + (window as any).ResizeObserver = jest.fn(() => ({ + observe, + disconnect: jest.fn(), + })); + + const controller = createController({ fullHeight: true }); + controller.onRender(); + expect(observe).toHaveBeenCalledWith(iFrame.parentElement); + controller.destroy(); + }); + + it('observes each resize target only once', () => { + const { controller, scrollContainer, observe } = mountInScrollContainer({ + fullHeight: true, + }); + // The parent is also the clipping ancestor, so it must not + // be observed twice. + jest.spyOn(iFrame, 'getBoundingClientRect').mockReturnValue(rectOf(-100, 400)); + jest.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue(rectOf(50, 300)); + + controller.onRender(); + expect(observe).toHaveBeenCalledTimes(1); + expect(observe).toHaveBeenCalledWith(scrollContainer); + controller.destroy(); + }); + + it('does not touch the containers when container lazy loading is off', () => { + const { controller, addContainerListener, resizeObserverCtor } = mountInScrollContainer( + { + fullHeight: true, + lazyLoadingForFullHeight: true, + enableScrollableContainerLazyLoading: false, + }, + ); + + controller.onRender(); + expect(addContainerListener).not.toHaveBeenCalled(); + expect(resizeObserverCtor).not.toHaveBeenCalled(); + }); + + it('still tracks the containers in an environment without ResizeObserver', () => { + const { controller, addContainerListener } = mountInScrollContainer( + { fullHeight: true }, + { withResizeObserver: false }, + ); + + expect(() => controller.onRender()).not.toThrow(); + expect(addContainerListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(() => controller.destroy()).not.toThrow(); + }); + + it('drops the previous containers when the embed re-renders', () => { + const { + controller, removeContainerListener, disconnect, + } = mountInScrollContainer({ fullHeight: true }); + + controller.onRender(); + controller.onRender(); + expect(removeContainerListener).toHaveBeenCalledWith('scroll', expect.any(Function)); + expect(disconnect).toHaveBeenCalledTimes(1); + controller.destroy(); + }); + + it('is safe to destroy more than once', () => { + const { controller } = mountInScrollContainer({ fullHeight: true }); + controller.onRender(); + controller.destroy(); + expect(() => controller.destroy()).not.toThrow(); + }); + + it('is safe to destroy before the embed has rendered', () => { + const controller = createController({ fullHeight: true }); + expect(() => controller.destroy()).not.toThrow(); + }); + }); +}); diff --git a/src/full-height.ts b/src/full-height.ts new file mode 100644 index 00000000..f06ed2fa --- /dev/null +++ b/src/full-height.ts @@ -0,0 +1,327 @@ +/** + * Copyright (c) 2025 + * + * Full-height support for the Liveboard and app embeds. + * @summary Full height + */ + +import { + BaseViewConfig, + EmbedEvent, + FullHeightViewConfig, + HostEvent, + MessageCallback, + MessagePayload, + Param, + QueryParams, +} from './types'; +import { + calculateElementCenter, + calculateVisibleElementData, + getEffectiveClippingAncestors, + getScrollableAncestors, + isValidCssMargin, +} from './utils'; +import { logger } from './utils/logger'; +import { DEFAULT_LAZY_LOADING_MARGIN } from './config'; + +/** + * The height the embed falls back to when the host app has not configured + * `minimumHeight` or `defaultHeight`. + */ +const DEFAULT_MINIMUM_HEIGHT = 500; + +/** + * Routes that are part of the Liveboard experience itself. Navigating between + * these does not reset the frame height, because the ThoughtSpot app keeps + * reporting a height of its own for them. + */ +const LIVEBOARD_RELATED_ROUTES = [ + '/pinboard/', + '/insights/pinboard/', + '/schedules/', + '/embed/viz/', + '/embed/insights/viz/', + '/liveboard/', + '/insights/liveboard/', + '/tsl-editor/PINBOARD_ANSWER_BOOK/', + '/import-tsl/PINBOARD_ANSWER_BOOK/', +]; + +/** + * The lazy-loading settings a full-height embed falls back to when the host app + * has not chosen its own. + * + * Pure by design: the embed owns its view config, so the embed applies the + * result itself rather than having this module write to it. + * @param viewConfig The embed's view config + * @returns The settings to apply, with the host app's own choices preserved + */ +export const resolveLazyLoadingDefaults = (viewConfig: FullHeightViewConfig) => ({ + lazyLoadingForFullHeight: + viewConfig.lazyLoadingForFullHeight === undefined + ? true + : viewConfig.lazyLoadingForFullHeight, + enableScrollableContainerLazyLoading: + viewConfig.enableScrollableContainerLazyLoading === undefined + ? true + : viewConfig.enableScrollableContainerLazyLoading, + lazyLoadingMargin: + viewConfig.lazyLoadingMargin === undefined + ? DEFAULT_LAZY_LOADING_MARGIN + : viewConfig.lazyLoadingMargin, +}); + +/** + * The subset of the embed the full-height controller drives. Keeping this + * narrow lets the controller stay independent of the embed class hierarchy. + */ +export interface FullHeightEmbedHost { + /** + * Returns the embedded iframe. The iframe only exists once the embed has + * rendered, so this is a callback rather than a value. + */ + getIframe: () => HTMLIFrameElement; + /** + * Sets the height of the embed container. + */ + setFrameHeight: (height: number | string) => void; + /** + * Registers an SDK-owned handler for an embed event. + */ + on: (eventType: EmbedEvent, callback: MessageCallback) => void; + /** + * Sends a host event to the embedded ThoughtSpot app. + */ + trigger: (hostEvent: HostEvent, data: unknown) => void; +} + +/** + * Owns every piece of full-height behavior for an embed: the height + * negotiation with the ThoughtSpot app, the query parameters that switch the + * feature on, and the viewport listeners that drive lazy loading. + * + * The controller is inert unless `fullHeight` is enabled, so embeds can create + * one unconditionally. + */ +export class FullHeightController { + private scrollContainers: HTMLElement[] = []; + + private resizeObserver: ResizeObserver | undefined; + + constructor( + private readonly viewConfig: FullHeightViewConfig & Pick, + private readonly host: FullHeightEmbedHost, + ) {} + + /** + * Whether the host app asked for a full-height embed. + */ + private get isEnabled(): boolean { + return this.viewConfig.fullHeight === true; + } + + /** + * Whether visualizations should load as they scroll into view, which + * requires the SDK to report the visible region of the embed. + */ + private get isLazyLoadEnabled(): boolean { + return this.isEnabled && !!this.viewConfig.lazyLoadingForFullHeight; + } + + /** + * The floor for the frame height. `defaultHeight` is the deprecated + * spelling of `minimumHeight` and is still honored for compatibility. + */ + public get minimumHeight(): number { + const { minimumHeight, defaultHeight } = this.viewConfig; + return minimumHeight || defaultHeight || DEFAULT_MINIMUM_HEIGHT; + } + + /** + * Registers the embed event handlers the feature depends on. Call this from + * the embed constructor, before `render`. + */ + public registerEventHandlers(): void { + if (!this.isEnabled) { + return; + } + this.host.on(EmbedEvent.RouteChange, this.handleRouteChange); + this.host.on(EmbedEvent.EmbedHeight, this.handleEmbedHeight); + this.host.on(EmbedEvent.EmbedIframeCenter, this.handleEmbedIframeCenter); + this.host.on( + EmbedEvent.RequestVisibleEmbedCoordinates, + this.handleRequestVisibleCoordinates, + ); + } + + /** + * Adds the full-height query parameters to the embed URL params. + * @param params The query parameters being built by the embed + */ + public addQueryParams(params: QueryParams): void { + if (!this.isEnabled) { + return; + } + params[Param.fullHeight] = true; + if (!this.viewConfig.lazyLoadingForFullHeight) { + return; + } + params[Param.IsLazyLoadingForEmbedEnabled] = true; + if (isValidCssMargin(this.viewConfig.lazyLoadingMargin)) { + params[Param.RootMarginForLazyLoad] = this.viewConfig.lazyLoadingMargin; + } + } + + /** + * Attaches the viewport listeners that keep lazy loading in sync. Call this + * once the embed has rendered and the iframe exists. + */ + public onRender(): void { + this.registerLazyLoadListeners(); + } + + /** + * Detaches every listener and observer owned by the controller. + */ + public destroy(): void { + this.unregisterLazyLoadListeners(); + } + + /** + * Sets the frame height to the height reported by the ThoughtSpot app, + * never going below the configured minimum. + */ + private handleEmbedHeight = (payload: MessagePayload): void => { + const height = Number(payload?.data); + if (!isNaN(height)) { + this.host.setFrameHeight(Math.max(height, this.minimumHeight)); + } + this.sendVisibleCoordinates(); + }; + + /** + * Answers the app's request for the center of the visible embed region. + */ + private handleEmbedIframeCenter = ( + payload: MessagePayload, + responder?: (data: any) => void, + ): void => { + const iframe = this.host.getIframe(); + if (!iframe) { + return; + } + responder?.({ + type: EmbedEvent.EmbedIframeCenter, + data: calculateElementCenter(iframe), + }); + }; + + /** + * Answers the app's request for the currently visible embed coordinates. + */ + private handleRequestVisibleCoordinates = ( + payload: MessagePayload, + responder?: (data: any) => void, + ): void => { + logger.info('Sending RequestVisibleEmbedCoordinates', payload); + responder?.({ + type: EmbedEvent.RequestVisibleEmbedCoordinates, + data: this.getVisibleCoordinates(), + }); + }; + + /** + * Resets the frame height when the app navigates away from a Liveboard, + * since only Liveboard routes report a height of their own. + */ + private handleRouteChange = (payload: MessagePayload): void => { + const currentPath: string = payload?.data?.currentPath; + if (!currentPath) { + return; + } + if (LIVEBOARD_RELATED_ROUTES.some((route) => currentPath.startsWith(route))) { + return; + } + this.host.setFrameHeight(this.viewConfig.frameParams?.height || this.minimumHeight); + }; + + /** + * Pushes the visible embed region to the app so it can decide which + * visualizations to load. + */ + private sendVisibleCoordinates = (): void => { + if (!this.isLazyLoadEnabled) { + return; + } + const coordinates = this.getVisibleCoordinates(); + if (coordinates) { + this.host.trigger(HostEvent.VisibleEmbedCoordinates, coordinates); + } + }; + + private getVisibleCoordinates() { + const iframe = this.host.getIframe(); + if (!iframe) { + return null; + } + return calculateVisibleElementData( + iframe, + this.viewConfig.enableScrollableContainerLazyLoading, + ); + } + + private registerLazyLoadListeners(): void { + if (!this.isLazyLoadEnabled || !this.host.getIframe()) { + return; + } + // Re-registering is safe: drop whatever a previous render attached. + this.unregisterLazyLoadListeners(); + // TODO: Use passive: true, install modernizr to check for passive + window.addEventListener('resize', this.sendVisibleCoordinates); + window.addEventListener('scroll', this.sendVisibleCoordinates, true); + if (!this.viewConfig.enableScrollableContainerLazyLoading) { + return; + } + this.observeScrollableContainers(); + } + + /** + * Tracks the ancestors that can scroll or clip the embed, so the visible + * region stays correct when the embed lives inside its own scroll + * container rather than the page. + */ + private observeScrollableContainers(): void { + const iFrame = this.host.getIframe(); + this.scrollContainers = getScrollableAncestors(iFrame); + this.scrollContainers.forEach((scrollContainer) => { + scrollContainer.addEventListener('scroll', this.sendVisibleCoordinates); + }); + if (typeof ResizeObserver === 'undefined') { + return; + } + const resizeTargets = new Set( + [iFrame.parentElement, ...getEffectiveClippingAncestors(iFrame)].filter( + Boolean, + ) as HTMLElement[], + ); + this.resizeObserver = new ResizeObserver(this.sendVisibleCoordinates); + resizeTargets.forEach((resizeTarget) => { + this.resizeObserver.observe(resizeTarget); + }); + } + + private unregisterLazyLoadListeners(): void { + if (!this.isLazyLoadEnabled) { + return; + } + window.removeEventListener('resize', this.sendVisibleCoordinates); + window.removeEventListener('scroll', this.sendVisibleCoordinates, true); + this.resizeObserver?.disconnect(); + this.resizeObserver = undefined; + this.scrollContainers.forEach((scrollContainer) => { + scrollContainer.removeEventListener('scroll', this.sendVisibleCoordinates); + }); + this.scrollContainers = []; + } +} diff --git a/src/types.ts b/src/types.ts index dca7d0cb..24fb1937 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1665,6 +1665,44 @@ export interface BaseViewConfig extends ApiInterceptFlags { * ``` */ useHostEventsV2?: boolean; + + /** + * Send the embed configuration to the ThoughtSpot application over + * postMessage (`HostEvent.UpdateEmbedParams`) as soon as the iframe signals + * that it is ready, instead of encoding it into the iframe `src`. + * + * Only the parameters needed to boot and authenticate the frame are kept on + * the URL: the embed marker, the host application URL, the SDK version, the + * authentication flags, the viewport size, the log level, the locale and + * formatting options, and the org override. Everything else - hidden and + * visible actions, hidden and visible tabs, data sources, customizations + * and `additionalFlags` - travels over postMessage. + * + * Use this to keep configuration out of the DOM and to keep the iframe URL + * short. Note two consequences before you turn it on: + * - The application briefly renders before the configuration arrives, so a + * flag that changes the initial layout can be applied a frame late. + * - The cluster must support `HostEvent.UpdateEmbedParams`. On a cluster + * that ignores it, the embed renders with default configuration. + * + * Runtime filters and parameters are not affected by this flag. They are + * already kept off the URL by `excludeRuntimeFiltersfromURL` and + * `excludeRuntimeParametersfromURL`, which both default to `true`. + * + * For a pre-rendered embed, the configuration is delivered by the existing + * pre-render path when `showPreRender()` is called, so the pre-rendered + * frame stays unconfigured while it warms up in the background. + * @default false + * @version SDK: 1.51.0 | ThoughtSpot Cloud: 26.8.0.cl + * @example + * ```js + * const embed = new LiveboardEmbed('#tsEmbed', { + * ... // other embed view config + * sendConfigAsPostMessage: true, + * }); + * ``` + */ + sendConfigAsPostMessage?: boolean; } /** @@ -9994,3 +10032,164 @@ export interface VisualizationOverrides { /** Table visualization overrides */ table?: TableOverrides; } + +/** + * The configuration object for the full-height behavior shared by the + * Liveboard and app embeds. + * + * When `fullHeight` is enabled the SDK resizes the embed container to match the + * height reported by the ThoughtSpot app, and — when lazy loading is also + * enabled — keeps the app informed of the portion of the embed that is + * currently visible so that visualizations load on demand. + */ +export interface FullHeightViewConfig { + /** + * If set to true, the embedded object container dynamically resizes + * according to the height of the Liveboard. + * + * **Note**: Using fullHeight loads all visualizations on the + * Liveboard simultaneously, which results in multiple warehouse + * queries and potentially a longer wait for the topmost + * visualizations to display on the screen. + * Setting `fullHeight` to `false` fetches visualizations + * incrementally as users scroll the page to view the charts and tables. + * + * From SDK 1.52.0, enabling `fullHeight` also turns on + * {@link lazyLoadingForFullHeight} and + * {@link enableScrollableContainerLazyLoading}, so visualizations load as + * they scroll into view. Set either flag to `false` to opt out. + * + * Supported embed types: `LiveboardEmbed`, `AppEmbed` + * @version SDK: 1.1.0 | ThoughtSpot: ts7.may.cl, 7.2.1 + * @example + * ```js + * // Replace with the embed component name. + * // For example, AppEmbed or LiveboardEmbed + * const embed = new ('#embed', { + * ... // other view config + * fullHeight: true, + * }); + * ``` + */ + fullHeight?: boolean; + /** + * This is the minimum height (in pixels) for a full-height Liveboard. + * Setting this height helps resolve issues with empty Liveboards and + * other screens navigable from a Liveboard. + * + * Supported embed types: `LiveboardEmbed`, `AppEmbed` + * @version SDK: 1.44.2 | ThoughtSpot: 10.15.0.cl + * @default 500 + * @example + * ```js + * // Replace with the embed component name. + * // For example, AppEmbed or LiveboardEmbed + * const embed = new ('#embed', { + * ... // other view config + * fullHeight: true, + * minimumHeight: 600, + * }); + * ``` + */ + minimumHeight?: number; + /** + * This is the minimum height (in pixels) for a full-height Liveboard. + * Setting this height helps resolve issues with empty Liveboards and + * other screens navigable from a Liveboard. + * + * Supported embed types: `LiveboardEmbed`, `AppEmbed` + * @version SDK: 1.5.0 | ThoughtSpot: ts7.oct.cl, 7.2.1 + * @deprecated Use `minimumHeight` instead. + * @default 500 + * @example + * ```js + * // Replace with the embed component name. + * // For example, AppEmbed or LiveboardEmbed + * const embed = new ('#embed', { + * ... // other view config + * fullHeight: true, + * defaultHeight: 600, + * }); + * ``` + */ + defaultHeight?: number; + /** + * Loads visualizations only as they scroll into the viewport, instead of + * loading the whole full-height Liveboard at once. + * + * From SDK 1.52.0 this is enabled automatically whenever `fullHeight` is + * `true`. On SDK 1.51.0 and earlier it defaulted to `false` and had to be + * set explicitly. Set it to `false` to load every visualization upfront. + * The flag has no effect unless `fullHeight` is enabled. + * + * Supported embed types: `LiveboardEmbed`, `AppEmbed` + * @type {boolean} + * @version SDK: 1.40.0 | ThoughtSpot: 10.12.0.cl + * @default true when `fullHeight` is enabled, from SDK 1.52.0 + * @example + * ```js + * // Replace with the embed component name. + * // For example, AppEmbed or LiveboardEmbed + * const embed = new ('#embed-container', { + * // ...other options + * fullHeight: true, + * lazyLoadingForFullHeight: true, + * }) + * ``` + */ + lazyLoadingForFullHeight?: boolean; + /** + * How far outside the viewport a visualization starts loading, when + * {@link lazyLoadingForFullHeight} is enabled. + * + * For example, if the margin is set to '10px', + * the visualization will be loaded 10px before its top edge is visible in the + * viewport. + * + * The format is similar to CSS margin, so `'500px 0px'` extends the + * prefetch 500px above and below the viewport and not sideways. Accepted + * units are `px`, `em`, `rem`, `%`, `vh` and `vw`, plus bare `0` and + * `auto`; an invalid value is logged and ignored. + * + * From SDK 1.52.0 this defaults to `'500px 0px'` — roughly one + * visualization ahead of the scroll position, so a chart has usually + * finished loading by the time it scrolls into view. Use a smaller margin + * to cut warehouse queries further, or `'0px'` to load a visualization + * only once it is actually visible. + * + * Supported embed types: `LiveboardEmbed`, `AppEmbed` + * @type {string} + * @version SDK: 1.40.0 | ThoughtSpot: 10.12.0.cl + * @default '500px 0px' when `fullHeight` is enabled, from SDK 1.52.0 + * @example + * ```js + * // Replace with the embed component name. + * // For example, AppEmbed or LiveboardEmbed + * const embed = new ('#embed-container', { + * // ...other options + * fullHeight: true, + * lazyLoadingForFullHeight: true, + * // With 0px, the visualization only starts loading once it is + * // visible in the viewport. + * lazyLoadingMargin: '0px', + * }) + * ``` + */ + lazyLoadingMargin?: string; + /** + * Computes the visible region of the embed against its scrollable and + * clipping ancestors, instead of treating the browser window as the only + * viewport, and tracks scroll and resize on those ancestors. + * + * From SDK 1.52.0 this is enabled automatically whenever `fullHeight` is + * `true`. On SDK 1.51.0 and earlier it defaulted to `false` and had to be + * set explicitly. Set it to `false` when the page scrolls with the window + * and the embed has no clipping ancestor, to skip the extra ancestor + * tracking. + * + * Supported embed types: `LiveboardEmbed`, `AppEmbed` + * @type {boolean} + * @default true when `fullHeight` is enabled, from SDK 1.52.0 + */ + enableScrollableContainerLazyLoading?: boolean; +} diff --git a/src/utils.spec.ts b/src/utils.spec.ts index eb12add6..4f43975d 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -31,6 +31,7 @@ import { deepMerge, getHostEventsConfig, isWindowUndefined, + calculateElementCenter, getOffsetTop, getDOMNode, getOperationNameFromQuery, @@ -1275,6 +1276,76 @@ describe('isWindowUndefined', () => { // --------------------------------------------------------------------------- // getOffsetTop // --------------------------------------------------------------------------- +describe('calculateElementCenter', () => { + const setViewport = (scrollY: number, innerHeight: number) => { + Object.defineProperty(window, 'scrollY', { value: scrollY, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: innerHeight, configurable: true }); + }; + + const mockElement = (offsetTop: number, offsetHeight: number) => ({ + getBoundingClientRect: () => ({ top: offsetTop - window.scrollY }), + offsetHeight, + }) as unknown as HTMLElement; + + test('centers on the element when it is shorter than the viewport', () => { + setViewport(0, 1000); + // Element sits at the top of the page and is 400px tall, fully visible. + const result = calculateElementCenter(mockElement(0, 400)); + expect(result).toEqual({ + iframeCenter: 200, + iframeScrolled: 0, + iframeHeight: 400, + viewPortHeight: 1000, + iframeVisibleViewPort: 400, + }); + }); + + test('centers on the visible slice when the element is taller than the viewport', () => { + setViewport(0, 600); + // 2000px element, only the top 600px is on screen. + const result = calculateElementCenter(mockElement(0, 2000)); + expect(result.iframeVisibleViewPort).toBe(600); + expect(result.iframeCenter).toBe(300); + expect(result.iframeScrolled).toBe(0); + }); + + test('tracks the center as the page scrolls into the element', () => { + setViewport(500, 600); + // Scrolled 500px into a 2000px element that starts at the page top. + const result = calculateElementCenter(mockElement(0, 2000)); + expect(result.iframeScrolled).toBe(500); + // 600px still visible, offset by the 500px already scrolled past. + expect(result.iframeVisibleViewPort).toBe(600); + expect(result.iframeCenter).toBe(800); + }); + + test('clamps the visible slice to what is left of the element', () => { + setViewport(1800, 600); + // Only the last 200px of the 2000px element remains below the fold. + const result = calculateElementCenter(mockElement(0, 2000)); + expect(result.iframeVisibleViewPort).toBe(200); + expect(result.iframeCenter).toBe(1900); + }); + + test('measures only the on-screen part when the element starts below the fold', () => { + setViewport(0, 1000); + // Element begins 800px down, so 200px of it is visible. + const result = calculateElementCenter(mockElement(800, 400)); + expect(result.iframeScrolled).toBe(-800); + expect(result.iframeVisibleViewPort).toBe(200); + expect(result.iframeCenter).toBe(100); + }); + + test('never reports more visible height than the element has', () => { + setViewport(0, 1000); + // Element is 100px tall and starts 200px down: the viewport has room to + // spare, so the visible slice is the element itself, not the gap. + const result = calculateElementCenter(mockElement(200, 100)); + expect(result.iframeVisibleViewPort).toBe(100); + expect(result.iframeCenter).toBe(50); + }); +}); + describe('getOffsetTop', () => { test('returns rect.top + window.scrollY', () => { const mockElement = { diff --git a/src/utils.ts b/src/utils.ts index 33786a2f..9ff6d1b7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -760,3 +760,41 @@ export const setParamIfDefined = ( queryParams[param] = asBoolean ? !!value : value; } }; + +/** + * Calculates the element center for the currently visible viewport of the + * element, using the scroll position of the host app, the offsetTop of the + * element in the host app, and the viewport height of the tab. + * + * The returned keys are sent to the ThoughtSpot app over postMessage, so they + * are named after the iframe and must not be renamed. + * @param element The element to measure + * @returns The element center within the visible viewport, the element height, + * and the viewport height. + */ +export const calculateElementCenter = (element: HTMLElement) => { + const offsetTopClient = getOffsetTop(element); + const scrollTopClient = window.scrollY; + const viewPortHeight = window.innerHeight; + const iframeHeight = element.offsetHeight; + const iframeScrolled = scrollTopClient - offsetTopClient; + let iframeVisibleViewPort; + let iframeOffset; + + if (iframeScrolled < 0) { + iframeVisibleViewPort = viewPortHeight - (offsetTopClient - scrollTopClient); + iframeVisibleViewPort = Math.min(iframeHeight, iframeVisibleViewPort); + iframeOffset = 0; + } else { + iframeVisibleViewPort = Math.min(iframeHeight - iframeScrolled, viewPortHeight); + iframeOffset = iframeScrolled; + } + const iframeCenter = iframeOffset + iframeVisibleViewPort / 2; + return { + iframeCenter, + iframeScrolled, + iframeHeight, + viewPortHeight, + iframeVisibleViewPort, + }; +};