diff --git a/src/embed/app.spec.ts b/src/embed/app.spec.ts index dc2d9ad1..9395ca1a 100644 --- a/src/embed/app.spec.ts +++ b/src/embed/app.spec.ts @@ -767,43 +767,7 @@ describe('App embed tests', () => { }); }); - test('should register event handlers to adjust iframe height', async () => { - let embedHeightCallback: any = () => { }; - const onSpy = jest.spyOn(AppEmbed.prototype, 'on').mockImplementation((event, callback) => { - if (event === EmbedEvent.RouteChange) { - callback({ data: { currentPath: '/answers' } }, jest.fn()); - } - if (event === EmbedEvent.EmbedHeight) { - embedHeightCallback = callback; - } - if (event === EmbedEvent.EmbedIframeCenter) { - callback({}, jest.fn()); - } - return null; - }); - jest.spyOn(TsEmbed.prototype as any, 'getIframeCenter').mockReturnValue({}); - jest.spyOn(TsEmbed.prototype as any, 'setIFrameHeight').mockReturnValue({}); - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - lazyLoadingForFullHeight: true, - } as AppViewConfig); - - // Set the iframe before render - (appEmbed as any).iFrame = document.createElement('iframe'); - // Wait for render to complete - await appEmbed.render(); - embedHeightCallback({ data: '100%' }); - - // 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()); - }, 100); - }); describe('Navigate to Page API', () => { const path = 'pinboard/e0836cad-4fdf-42d4-bd97-567a6b2a6058'; @@ -891,384 +855,9 @@ describe('App embed tests', () => { }); }); - describe('LazyLoadingForFullHeight functionality', () => { - let mockIFrame: HTMLIFrameElement; - - beforeEach(() => { - mockIFrame = document.createElement('iframe'); - mockIFrame.getBoundingClientRect = jest.fn().mockReturnValue({ - top: 100, - left: 150, - bottom: 600, - right: 800, - width: 650, - height: 500, - }); - jest.spyOn(document, 'createElement').mockImplementation((tagName) => { - if (tagName === 'iframe') { - return mockIFrame; - } - return document.createElement(tagName); - }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - test('should set lazyLoadingMargin parameter when provided', async () => { - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - lazyLoadingForFullHeight: true, - lazyLoadingMargin: '100px 0px', - } as AppViewConfig); - - await appEmbed.render(); - - await executeAfterWait(() => { - const iframeSrc = getIFrameSrc(); - expect(iframeSrc).toContain('isLazyLoadingForEmbedEnabled=true'); - expect(iframeSrc).toContain('isFullHeightPinboard=true'); - expect(iframeSrc).toContain('rootMarginForLazyLoad=100px%200px'); - }, 100); - }); - - test('should set isLazyLoadingForEmbedEnabled=true when both fullHeight and lazyLoadingForFullHeight are enabled', async () => { - // Mock the iframe element first - mockIFrame.getBoundingClientRect = jest.fn().mockReturnValue({ - top: 100, - left: 150, - bottom: 600, - right: 800, - width: 650, - height: 500, - }); - Object.defineProperty(mockIFrame, 'scrollHeight', { value: 500 }); - - // Mock the event handlers - 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 - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - lazyLoadingForFullHeight: true, - } as AppViewConfig); - - // Set the iframe before render - (appEmbed as any).iFrame = mockIFrame; - - // Add the iframe to the DOM - const rootEl = getRootEl(); - rootEl.appendChild(mockIFrame); - - // Wait for render to complete - await appEmbed.render(); - - // Wait for iframe initialization and URL parameters to be set - await executeAfterWait(() => { - const iframeSrc = appEmbed.getIFrameSrc(); - expect(iframeSrc).toContain('isLazyLoadingForEmbedEnabled=true'); - expect(iframeSrc).toContain('isFullHeightPinboard=true'); - }, 100); - }); - - test('should not set lazyLoadingForEmbed when lazyLoadingForFullHeight is enabled but fullHeight is false', async () => { - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: false, - lazyLoadingForFullHeight: true, - } as AppViewConfig); - - // Wait for render to complete - await appEmbed.render(); - - // Wait for iframe initialization and URL parameters to be set - await executeAfterWait(() => { - const iframeSrc = getIFrameSrc(); - expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled=true'); - expect(iframeSrc).not.toContain('isFullHeightPinboard=true'); - }, 100); // 100ms wait time to ensure iframe src is set - }); - - test('should not set isLazyLoadingForEmbedEnabled when fullHeight is true but lazyLoadingForFullHeight is false', async () => { - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - lazyLoadingForFullHeight: false, - } as AppViewConfig); - - // Wait for render to complete - await appEmbed.render(); - - // Wait for iframe initialization and URL parameters to be set - await executeAfterWait(() => { - const iframeSrc = getIFrameSrc(); - expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled=true'); - expect(iframeSrc).toContain('isFullHeightPinboard=true'); - }, 100); // 100ms wait time to ensure iframe src is set - }); - - test('should register RequestFullHeightLazyLoadData event handler when fullHeight is enabled', async () => { - const onSpy = jest.spyOn(AppEmbed.prototype, 'on'); - - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - } as AppViewConfig); - - await appEmbed.render(); - - expect(onSpy).toHaveBeenCalledWith(EmbedEvent.RequestVisibleEmbedCoordinates, expect.any(Function)); - - onSpy.mockRestore(); - }); - - test('should send correct visible data when RequestFullHeightLazyLoadData is triggered', async () => { - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - lazyLoadingForFullHeight: true, - } as AppViewConfig); - - const mockTrigger = jest.spyOn(appEmbed, 'trigger'); - - await appEmbed.render(); - - // Trigger the lazy load data calculation - (appEmbed as any).sendFullHeightLazyLoadData(); - - expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { - top: 0, - height: 500, - left: 0, - width: 650, - }); - }); - - test('should calculate correct visible data for partially visible full height element', async () => { - // Mock iframe partially clipped from top and left - mockIFrame.getBoundingClientRect = jest.fn().mockReturnValue({ - top: -50, - left: -30, - bottom: 700, - right: 1024, - width: 1054, - height: 750, - }); - - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - lazyLoadingForFullHeight: true, - } as AppViewConfig); - - const mockTrigger = jest.spyOn(appEmbed, 'trigger'); - - await appEmbed.render(); - - // Trigger the lazy load data calculation - (appEmbed as any).sendFullHeightLazyLoadData(); - - expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { - top: 50, // 50px clipped from top - height: 700, // visible height (from 0 to 700) - left: 30, // 30px clipped from left - width: 1024, // visible width (from 0 to 1024) - }); - }); - - test('should add window event listeners for resize and scroll when fullHeight and lazyLoadingForFullHeight are enabled', async () => { - const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - lazyLoadingForFullHeight: true, - } as AppViewConfig); - - await appEmbed.render(); - - // Wait for the post-render events to be registered - await executeAfterWait(() => { - expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); - expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true); - }, 100); - - addEventListenerSpy.mockRestore(); - }); - - test('should remove window event listeners on destroy when fullHeight and lazyLoadingForFullHeight are enabled', async () => { - const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - lazyLoadingForFullHeight: true, - } as AppViewConfig); - - await appEmbed.render(); - appEmbed.destroy(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); - expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); - - removeEventListenerSpy.mockRestore(); - }); - - test('should handle RequestVisibleEmbedCoordinates event and respond with correct data', async () => { - // Mock the iframe element - mockIFrame.getBoundingClientRect = jest.fn().mockReturnValue({ - top: 100, - left: 150, - bottom: 600, - right: 800, - width: 650, - height: 500, - }); - Object.defineProperty(mockIFrame, 'scrollHeight', { value: 500 }); - - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - lazyLoadingForFullHeight: true, - } as AppViewConfig); - - // Set the iframe before render - (appEmbed as any).iFrame = mockIFrame; - - await appEmbed.render(); - - // Create a mock responder function - const mockResponder = jest.fn(); - - // Trigger the handler directly - (appEmbed as any).requestVisibleEmbedCoordinatesHandler({}, mockResponder); - - // Verify the responder was called with the correct data - expect(mockResponder).toHaveBeenCalledWith({ - type: EmbedEvent.RequestVisibleEmbedCoordinates, - data: { - top: 0, - height: 500, - left: 0, - width: 650, - }, - }); - }); - }); - - describe('IFrame height management', () => { - let mockIFrame: HTMLIFrameElement; - beforeEach(() => { - mockIFrame = document.createElement('iframe'); - mockIFrame.getBoundingClientRect = jest.fn().mockReturnValue({ - top: 100, - left: 150, - bottom: 600, - right: 800, - width: 650, - height: 500, - }); - Object.defineProperty(mockIFrame, 'scrollHeight', { value: 500 }); - }); - test('should not call setIFrameHeight if currentPath starts with "/embed/viz/"', () => { - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - } as AppViewConfig) as any; - const spySetIFrameHeight = jest.spyOn(appEmbed, 'setIFrameHeight'); - - appEmbed.render(); - appEmbed.setIframeHeightForNonEmbedLiveboard({ - data: { currentPath: '/embed/viz/' }, - type: 'Route', - }); - - expect(spySetIFrameHeight).not.toHaveBeenCalled(); - }); - test('should not call setIFrameHeight if currentPath starts with "/embed/insights/viz/"', () => { - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - } as AppViewConfig) as any; - const spySetIFrameHeight = jest.spyOn(appEmbed, 'setIFrameHeight'); - - appEmbed.render(); - appEmbed.setIframeHeightForNonEmbedLiveboard({ - data: { currentPath: '/embed/insights/viz/' }, - type: 'Route', - }); - expect(spySetIFrameHeight).not.toHaveBeenCalled(); - }); - test('should call setIFrameHeight if currentPath starts with "/some/other/path/"', () => { - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - } as AppViewConfig) as any; - const spySetIFrameHeight = jest - .spyOn(appEmbed, 'setIFrameHeight') - .mockImplementation(jest.fn()); - - appEmbed.render(); - appEmbed.setIframeHeightForNonEmbedLiveboard({ - data: { currentPath: '/some/other/path/' }, - type: 'Route', - }); - - expect(spySetIFrameHeight).toHaveBeenCalled(); - }); - - test('should update iframe height correctly', async () => { - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - } as AppViewConfig) as any; - - // Set up the mock iframe - appEmbed.iFrame = mockIFrame; - document.body.appendChild(mockIFrame); - - await appEmbed.render(); - const mockEvent = { - data: 600, - type: EmbedEvent.EmbedHeight, - }; - appEmbed.updateIFrameHeight(mockEvent); - - // Check if the iframe style was updated - expect(mockIFrame.style.height).toBe('600px'); - }); - - test('should handle updateIFrameHeight with default height', async () => { - const appEmbed = new AppEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - } as AppViewConfig) as any; - - // Set up the mock iframe - appEmbed.iFrame = mockIFrame; - document.body.appendChild(mockIFrame); - - await appEmbed.render(); - const mockEvent = { - data: 0, // This will make it use the scrollHeight - type: EmbedEvent.EmbedHeight, - }; - appEmbed.updateIFrameHeight(mockEvent); - - // Should use the scrollHeight - expect(mockIFrame.style.height).toBe('500px'); - }); - }); }); diff --git a/src/embed/app.ts b/src/embed/app.ts index 49ed72c4..2c831018 100644 --- a/src/embed/app.ts +++ b/src/embed/app.ts @@ -17,8 +17,10 @@ import { EmbedEvent, MessagePayload, AllEmbedViewConfig, + FullHeightViewConfig, } from '../types'; import { V1Embed } from './ts-embed'; +import { FullHeight } from '../full-height'; /** * Pages within the ThoughtSpot app that can be embedded. @@ -148,7 +150,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. @@ -403,29 +405,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. - * - * 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; /** * Flag to control new Modular Home experience. * @@ -531,48 +510,6 @@ export interface AppViewConfig extends AllEmbedViewConfig { * ``` */ isLiveboardStylingAndGroupingEnabled?: boolean; - - /** - * This flag is used to enable the full height lazy load data. - * - * @example - * ```js - * const embed = new AppEmbed('#embed-container', { - * // ...other options - * fullHeight: true, - * lazyLoadingForFullHeight: true, - * }) - * ``` - * - * @type {boolean} - * @default false - * @version SDK: 1.40.0 | ThoughtSpot:10.12.0.cl - */ - lazyLoadingForFullHeight?: boolean; - - /** - * The margin to be used for lazy loading. - * - * For example, if the margin is set to '10px', - * the visualization will be loaded 10px before the its top edge is visible in the - * viewport. - * - * The format is similar to CSS margin. - * - * @example - * ```js - * const embed = new AppEmbed('#embed-container', { - * // ...other options - * fullHeight: true, - * lazyLoadingForFullHeight: true, - * // Using 0px, the visualization will be only loaded when its visible in the viewport. - * lazyLoadingMargin: '0px', - * }) - * ``` - * @type {string} - * @version SDK: 1.40.0 | ThoughtSpot:10.12.0.cl - */ - lazyLoadingMargin?: string; } /** @@ -582,17 +519,18 @@ export interface AppViewConfig extends AllEmbedViewConfig { export class AppEmbed extends V1Embed { protected viewConfig: AppViewConfig; - private defaultHeight = '100%'; - + private fullHeightClient: FullHeight | null = null; constructor(domSelector: DOMSelector, viewConfig: AppViewConfig) { viewConfig.embedComponentType = 'AppEmbed'; super(domSelector, viewConfig); if (this.viewConfig.fullHeight === true) { - this.on(EmbedEvent.RouteChange, this.setIframeHeightForNonEmbedLiveboard); - this.on(EmbedEvent.EmbedHeight, this.updateIFrameHeight); - this.on(EmbedEvent.EmbedIframeCenter, this.embedIframeCenter); - this.on(EmbedEvent.RequestVisibleEmbedCoordinates, this.requestVisibleEmbedCoordinatesHandler); + this.fullHeightClient = new FullHeight({ + getIframe: () => this.iFrame, + onEmbedEvent: (event, callback) => this.on(event, callback), + getViewConfig: () => this.viewConfig, + triggerHostEvent: (event, data) => this.trigger(event, data), + }); } } @@ -672,13 +610,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; - params[Param.RootMarginForLazyLoad] = this.viewConfig.lazyLoadingMargin; - } - } + params = this.fullHeightClient?.getParamsForFullHeight(params) || params; if (tag) { params[Param.Tag] = tag; @@ -794,44 +726,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.iFrame?.scrollHeight)); - 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. @@ -916,26 +810,11 @@ export class AppEmbed extends V1Embed { */ public destroy() { super.destroy(); - this.unregisterLazyLoadEvents(); + this.fullHeightClient?.cleanup(); } private postRender() { - this.registerLazyLoadEvents(); - } - - private registerLazyLoadEvents() { - if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { - // TODO: Use passive: true, install modernizr to check for passive - window.addEventListener('resize', this.sendFullHeightLazyLoadData); - window.addEventListener('scroll', this.sendFullHeightLazyLoadData, true); - } - } - - private unregisterLazyLoadEvents() { - if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { - window.removeEventListener('resize', this.sendFullHeightLazyLoadData); - window.removeEventListener('scroll', this.sendFullHeightLazyLoadData); - } + this.fullHeightClient?.init(); } /** diff --git a/src/embed/liveboard.spec.ts b/src/embed/liveboard.spec.ts index e58ea116..bb945cf7 100644 --- a/src/embed/liveboard.spec.ts +++ b/src/embed/liveboard.spec.ts @@ -434,77 +434,7 @@ describe('Liveboard/viz embed tests', () => { }); }); - test('should register event handler to adjust iframe height', async () => { - const onSpy = jest.spyOn(LiveboardEmbed.prototype, 'on'); - const liveboardEmbed = new LiveboardEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - liveboardId, - vizId, - } as LiveboardViewConfig); - - liveboardEmbed.render(); - - executeAfterWait(() => { - expect(onSpy).toHaveBeenCalledWith(EmbedEvent.EmbedHeight, expect.anything()); - }); - }); - - test('should not call setIFrameHeight if currentPath starts with "/embed/viz/"', () => { - const myObject = new LiveboardEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - liveboardId, - } as LiveboardViewConfig) as any; - const spySetIFrameHeight = jest.spyOn(myObject, 'setIFrameHeight'); - - myObject.render(); - myObject.setIframeHeightForNonEmbedLiveboard({ - data: { currentPath: '/embed/viz/' }, - type: 'Route', - }); - - // Assert that setIFrameHeight is not called - expect(spySetIFrameHeight).not.toHaveBeenCalled(); - }); - test('should not call setIFrameHeight if currentPath starts with "/embed/insights/viz/"', () => { - const myObject = new LiveboardEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - liveboardId, - } as LiveboardViewConfig) as any; - const spySetIFrameHeight = jest.spyOn(myObject, 'setIFrameHeight'); - - myObject.render(); - myObject.setIframeHeightForNonEmbedLiveboard({ - data: { currentPath: '/embed/insights/viz/' }, - type: 'Route', - }); - - // Assert that setIFrameHeight is not called - expect(spySetIFrameHeight).not.toHaveBeenCalled(); - }); - - test('should call setIFrameHeight if currentPath starts with "/some/other/path/"', () => { - const myObject = new LiveboardEmbed(getRootEl(), { - ...defaultViewConfig, - fullHeight: true, - liveboardId, - } as LiveboardViewConfig) as any; - const spySetIFrameHeight = jest - .spyOn(myObject, 'setIFrameHeight') - .mockImplementation(jest.fn()); - - myObject.render(); - myObject.setIframeHeightForNonEmbedLiveboard({ - data: { currentPath: '/some/other/path/' }, - type: 'Route', - }); - - // Assert that setIFrameHeight is not called - expect(spySetIFrameHeight).toHaveBeenCalled(); - }); test('Should set the visible vizs', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { @@ -814,258 +744,7 @@ describe('Liveboard/viz embed tests', () => { }); }); - describe('LazyLoadingForFullHeight functionality', () => { - let mockIFrame: HTMLIFrameElement; - beforeEach(() => { - mockIFrame = document.createElement('iframe'); - mockIFrame.getBoundingClientRect = jest.fn().mockReturnValue({ - top: 100, - left: 150, - bottom: 600, - right: 800, - width: 650, - height: 500, - }); - jest.spyOn(document, 'createElement').mockImplementation((tagName) => { - if (tagName === 'iframe') { - return mockIFrame; - } - return document.createElement(tagName); - }); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - test('should set lazyLoadingMargin parameter when provided', async () => { - const liveboardEmbed = new LiveboardEmbed(getRootEl(), { - ...defaultViewConfig, - liveboardId, - fullHeight: true, - lazyLoadingForFullHeight: true, - lazyLoadingMargin: '100px 0px', - } as LiveboardViewConfig); - - await liveboardEmbed.render(); - - await executeAfterWait(() => { - const iframeSrc = getIFrameSrc(); - expect(iframeSrc).toContain('isLazyLoadingForEmbedEnabled=true'); - expect(iframeSrc).toContain('isFullHeightPinboard=true'); - expect(iframeSrc).toContain('rootMarginForLazyLoad=100px%200px'); - }, 100); - }); - - test('should set isLazyLoadingForEmbedEnabled=true when both fullHeight and lazyLoadingForFullHeight are enabled', async () => { - const liveboardEmbed = new LiveboardEmbed(getRootEl(), { - ...defaultViewConfig, - liveboardId, - fullHeight: true, - lazyLoadingForFullHeight: true, - } as LiveboardViewConfig); - - await liveboardEmbed.render(); - - await executeAfterWait(() => { - const iframeSrc = getIFrameSrc(); - expect(iframeSrc).toContain('isLazyLoadingForEmbedEnabled=true'); - expect(iframeSrc).toContain('isFullHeightPinboard=true'); - }, 100); - }); - - test('should not set lazyLoadingForEmbed when lazyLoadingForFullHeight is enabled but fullHeight is false', async () => { - const liveboardEmbed = new LiveboardEmbed(getRootEl(), { - ...defaultViewConfig, - liveboardId, - fullHeight: false, - lazyLoadingForFullHeight: true, - } as LiveboardViewConfig); - - await liveboardEmbed.render(); - - await executeAfterWait(() => { - const iframeSrc = getIFrameSrc(); - expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled=true'); - expect(iframeSrc).not.toContain('isFullHeightPinboard=true'); - }, 100); - }); - - test('should not set isLazyLoadingForEmbedEnabled when fullHeight is true but lazyLoadingForFullHeight is false', async () => { - const liveboardEmbed = new LiveboardEmbed(getRootEl(), { - ...defaultViewConfig, - liveboardId, - fullHeight: true, - lazyLoadingForFullHeight: false, - } as LiveboardViewConfig); - - await liveboardEmbed.render(); - - await executeAfterWait(() => { - const iframeSrc = getIFrameSrc(); - expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled=true'); - expect(iframeSrc).toContain('isFullHeightPinboard=true'); - }, 100); - }); - - test('should register event handlers to adjust iframe height', async () => { - const onSpy = jest.spyOn(LiveboardEmbed.prototype, 'on'); - - const liveboardEmbed = new LiveboardEmbed(getRootEl(), { - ...defaultViewConfig, - liveboardId, - fullHeight: true, - lazyLoadingForFullHeight: true, - } as LiveboardViewConfig); - - await liveboardEmbed.render(); - - 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()); - }, 100); - }); - - test('should send correct visible data when RequestVisibleEmbedCoordinates is triggered', async () => { - const liveboardEmbed = new LiveboardEmbed(getRootEl(), { - ...defaultViewConfig, - liveboardId, - fullHeight: true, - lazyLoadingForFullHeight: true, - } as LiveboardViewConfig); - - const mockTrigger = jest.spyOn(liveboardEmbed, 'trigger'); - - await liveboardEmbed.render(); - - // Trigger the lazy load data calculation - (liveboardEmbed as any).sendFullHeightLazyLoadData(); - - expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { - top: 0, - height: 500, - left: 0, - width: 650, - }); - }); - - test('should calculate correct visible data for partially visible full height element', async () => { - mockIFrame.getBoundingClientRect = jest.fn().mockReturnValue({ - top: -50, - left: -30, - bottom: 700, - right: 1024, - width: 1054, - height: 750, - }); - - const liveboardEmbed = new LiveboardEmbed(getRootEl(), { - ...defaultViewConfig, - liveboardId, - fullHeight: true, - lazyLoadingForFullHeight: true, - } as LiveboardViewConfig); - - const mockTrigger = jest.spyOn(liveboardEmbed, 'trigger'); - - await liveboardEmbed.render(); - - // Trigger the lazy load data calculation - (liveboardEmbed as any).sendFullHeightLazyLoadData(); - - expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { - top: 50, - height: 700, - left: 30, - width: 1024, - }); - }); - - test('should add window event listeners for resize and scroll when fullHeight and lazyLoadingForFullHeight are enabled', async () => { - const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); - - const liveboardEmbed = new LiveboardEmbed(getRootEl(), { - ...defaultViewConfig, - liveboardId, - fullHeight: true, - lazyLoadingForFullHeight: true, - } as LiveboardViewConfig); - - await liveboardEmbed.render(); - - // Wait for the post-render events to be registered - await executeAfterWait(() => { - expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything()); - expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything(), true); - }, 100); - - addEventListenerSpy.mockRestore(); - }); - - test('should remove window event listeners on destroy when fullHeight and lazyLoadingForFullHeight are enabled', async () => { - const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - - const liveboardEmbed = new LiveboardEmbed(getRootEl(), { - ...defaultViewConfig, - liveboardId, - fullHeight: true, - lazyLoadingForFullHeight: true, - } as LiveboardViewConfig); - - await liveboardEmbed.render(); - liveboardEmbed.destroy(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything()); - expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything()); - - removeEventListenerSpy.mockRestore(); - }); - - test('should handle RequestVisibleEmbedCoordinates event and respond with correct data', async () => { - // Mock the iframe element - mockIFrame.getBoundingClientRect = jest.fn().mockReturnValue({ - top: 100, - left: 150, - bottom: 600, - right: 800, - width: 650, - height: 500, - }); - Object.defineProperty(mockIFrame, 'scrollHeight', { value: 500 }); - - const liveboardEmbed = new LiveboardEmbed(getRootEl(), { - ...defaultViewConfig, - liveboardId, - fullHeight: true, - lazyLoadingForFullHeight: true, - } as LiveboardViewConfig); - - // Set the iframe before render - (liveboardEmbed as any).iFrame = mockIFrame; - - await liveboardEmbed.render(); - - // Create a mock responder function - const mockResponder = jest.fn(); - - // Trigger the handler directly - (liveboardEmbed as any).requestVisibleEmbedCoordinatesHandler({}, mockResponder); - - // Verify the responder was called with the correct data - expect(mockResponder).toHaveBeenCalledWith({ - type: EmbedEvent.RequestVisibleEmbedCoordinates, - data: { - top: 0, - height: 500, - left: 0, - width: 650, - }, - }); - }); - }); describe('Host events for liveborad', () => { test('Host event with empty param', async () => { diff --git a/src/embed/liveboard.ts b/src/embed/liveboard.ts index 6cbfd6c8..df007c34 100644 --- a/src/embed/liveboard.ts +++ b/src/embed/liveboard.ts @@ -20,61 +20,21 @@ import { SearchLiveboardCommonViewConfig as LiveboardOtherViewConfig, BaseViewConfig, LiveboardAppEmbedViewConfig, + FullHeightViewConfig, } from '../types'; -import { calculateVisibleElementData, getQueryParamString, isUndefined } from '../utils'; -import { getAuthPromise } from './base'; +import { getQueryParamString, isUndefined } from '../utils'; import { TsEmbed, V1Embed } from './ts-embed'; import { addPreviewStylesIfNotPresent } from '../utils/global-styles'; import { TriggerPayload, TriggerResponse } from './hostEventClient/contracts'; import { logger } from '../utils/logger'; +import { FullHeight } from '../full-height'; /** * 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. - * - * @version SDK: 1.1.0 | ThoughtSpot: ts7.may.cl, 7.2.1 - * - * Supported embed types: `LiveboardEmbed` - * @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 - * @default 500 - * @example - * ```js - * const embed = new LiveboardEmbed('#embed', { - * ... // other liveboard view config - * fullHeight: true, - * defaultHeight: 600, - * }); - * ``` - */ - defaultHeight?: number; +export interface LiveboardViewConfig extends BaseViewConfig, LiveboardOtherViewConfig, LiveboardAppEmbedViewConfig, FullHeightViewConfig { /** * @Deprecated If set to true, the context menu in visualizations will be enabled. * @example @@ -324,46 +284,6 @@ export interface LiveboardViewConfig extends BaseViewConfig, LiveboardOtherViewC * ``` */ isLiveboardStylingAndGroupingEnabled?: boolean; - /** - * This flag is used to enable the full height lazy load data. - * - * @example - * ```js - * const embed = new LiveboardEmbed('#embed-container', { - * // ...other options - * fullHeight: true, - * lazyLoadingForFullHeight: true, - * }) - * ``` - * - * @type {boolean} - * @default false - * @version SDK: 1.40.0 | ThoughtSpot:10.12.0.cl - */ - lazyLoadingForFullHeight?: boolean; - /** - * The margin to be used for lazy loading. - * - * For example, if the margin is set to '10px', - * the visualization will be loaded 10px before the its top edge is visible in the - * viewport. - * - * The format is similar to CSS margin. - * - * @example - * ```js - * const embed = new LiveboardEmbed('#embed-container', { - * // ...other options - * fullHeight: true, - * lazyLoadingForFullHeight: true, - * // Using 0px, the visualization will be only loaded when its visible in the viewport. - * lazyLoadingMargin: '0px', - * }) - * ``` - * @type {string} - * @version SDK: 1.40.0 | ThoughtSpot:10.12.0.cl - */ - lazyLoadingMargin?: string; } /** @@ -383,9 +303,7 @@ export interface LiveboardViewConfig extends BaseViewConfig, LiveboardOtherViewC */ export class LiveboardEmbed extends V1Embed { protected viewConfig: LiveboardViewConfig; - - private defaultHeight = 500; - + private fullHeightClient: FullHeight | null = null; constructor(domSelector: DOMSelector, viewConfig: LiveboardViewConfig) { viewConfig.embedComponentType = 'LiveboardEmbed'; @@ -395,11 +313,12 @@ export class LiveboardEmbed extends V1Embed { 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); + this.fullHeightClient = new FullHeight({ + getIframe: () => this.iFrame, + onEmbedEvent: (event, callback) => this.on(event, callback), + getViewConfig: () => this.viewConfig, + triggerHostEvent: (event, data) => this.trigger(event, data), + }); } } @@ -413,7 +332,6 @@ export class LiveboardEmbed extends V1Embed { const { enableVizTransformations, fullHeight, - defaultHeight, visibleVizs, liveboardV2, vizId, @@ -441,16 +359,8 @@ 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; - params[Param.RootMarginForLazyLoad] = this.viewConfig.lazyLoadingMargin; - } - } - if (defaultHeight) { - this.defaultHeight = defaultHeight; - } + params = this.fullHeightClient?.getParamsForFullHeight(params) || params; + if (enableVizTransformations !== undefined) { params[Param.EnableVizTransformations] = enableVizTransformations.toString(); } @@ -529,23 +439,6 @@ export class LiveboardEmbed extends V1Embed { return suffix; } - private sendFullHeightLazyLoadData = () => { - const data = calculateVisibleElementData(this.iFrame); - 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); - responder({ type: EmbedEvent.RequestVisibleEmbedCoordinates, data: visibleCoordinatesData }); - } - /** * Construct the URL of the embedded ThoughtSpot Liveboard or visualization * to be loaded within the iFrame. @@ -564,44 +457,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]; @@ -691,27 +546,13 @@ export class LiveboardEmbed extends V1Embed { */ public destroy() { super.destroy(); - this.unregisterLazyLoadEvents(); + this.fullHeightClient?.cleanup(); } private postRender() { - this.registerLazyLoadEvents(); + this.fullHeightClient?.init(); } - private registerLazyLoadEvents() { - if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { - // TODO: Use passive: true, install modernizr to check for passive - window.addEventListener('resize', this.sendFullHeightLazyLoadData); - window.addEventListener('scroll', this.sendFullHeightLazyLoadData, true); - } - } - - private unregisterLazyLoadEvents() { - if (this.viewConfig.fullHeight && this.viewConfig.lazyLoadingForFullHeight) { - window.removeEventListener('resize', this.sendFullHeightLazyLoadData); - window.removeEventListener('scroll', this.sendFullHeightLazyLoadData); - } - } /** * Render an embedded ThoughtSpot Liveboard or visualization diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 278318ea..83829898 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -944,13 +944,6 @@ export class TsEmbed { } } - /** - * Sets the height of the iframe - * @param height The height in pixels - */ - protected setIFrameHeight(height: number | string): void { - this.iFrame.style.height = getCssDimension(height); - } /** * Executes all registered event handlers for a particular event type @@ -1000,40 +993,7 @@ 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 diff --git a/src/full-height.spec.ts b/src/full-height.spec.ts new file mode 100644 index 00000000..67bac06d --- /dev/null +++ b/src/full-height.spec.ts @@ -0,0 +1,697 @@ +import { FullHeight } from './full-height'; +import { AppEmbed, AppViewConfig } from './embed/app'; +import { LiveboardEmbed, LiveboardViewConfig } from './embed/liveboard'; +import { init } from './index'; +import { AuthType, EmbedEvent, HostEvent } from './types'; +import { + executeAfterWait, + getDocumentBody, + getIFrameSrc, + getRootEl, + defaultParams, +} from './test/test-utils'; +import { TsEmbed } from './embed/ts-embed'; +import * as auth from './auth'; + +const thoughtSpotHost = 'tshost'; +const liveboardId = 'eca215d4-0d2c-4a55-90e3-d81ef6848ae0'; +const vizId = '6e73f724-660e-11eb-ae93-0242ac130002'; + +const defaultViewConfig = { + frameParams: { + width: 1280, + height: 720, + }, +}; + +beforeAll(() => { + init({ + thoughtSpotHost, + authType: AuthType.None, + }); + jest.spyOn(auth, 'postLoginService').mockImplementation(() => Promise.resolve({})); +}); + +describe('FullHeight functionality', () => { + beforeEach(() => { + document.body.innerHTML = getDocumentBody(); + }); + + describe('Event registration and height management', () => { + test('should register event handler to adjust iframe height for LiveboardEmbed', async () => { + const onSpy = jest.spyOn(LiveboardEmbed.prototype, 'on'); + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + liveboardId, + vizId, + } as LiveboardViewConfig); + + liveboardEmbed.render(); + + executeAfterWait(() => { + expect(onSpy).toHaveBeenCalledWith(EmbedEvent.EmbedHeight, expect.anything()); + }); + }); + + test('should register event handler to adjust iframe height for AppEmbed', async () => { + const onSpy = jest.spyOn(AppEmbed.prototype, 'on'); + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as AppViewConfig); + + await appEmbed.render(); + + // 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()); + }, 100); + }); + }); + + describe('IFrame height management for different paths', () => { + test('should not call setIFrameHeight if currentPath starts with "/embed/viz/" (LiveboardEmbed)', () => { + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + liveboardId, + } as LiveboardViewConfig); + + liveboardEmbed.render(); + + // Access the fullHeightClient and spy on its setIFrameHeight method + const fullHeightClient = (liveboardEmbed as any).fullHeightClient; + const spySetIFrameHeight = jest.spyOn(fullHeightClient, 'setIFrameHeight' as any); + + // Simulate the route change event handler + fullHeightClient.setIframeHeightForNonEmbedLiveboard({ + data: { currentPath: '/embed/viz/' }, + type: 'Route', + }); + + expect(spySetIFrameHeight).not.toHaveBeenCalled(); + }); + + test('should not call setIFrameHeight if currentPath starts with "/embed/insights/viz/" (LiveboardEmbed)', () => { + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + liveboardId, + } as LiveboardViewConfig); + + liveboardEmbed.render(); + + // Access the fullHeightClient and spy on its setIFrameHeight method + const fullHeightClient = (liveboardEmbed as any).fullHeightClient; + const spySetIFrameHeight = jest.spyOn(fullHeightClient, 'setIFrameHeight' as any); + + // Simulate the route change event handler + fullHeightClient.setIframeHeightForNonEmbedLiveboard({ + data: { currentPath: '/embed/insights/viz/' }, + type: 'Route', + }); + + expect(spySetIFrameHeight).not.toHaveBeenCalled(); + }); + + test('should call setIFrameHeight if currentPath starts with "/some/other/path/" (LiveboardEmbed)', () => { + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + liveboardId, + } as LiveboardViewConfig); + + liveboardEmbed.render(); + + // Set up the iframe + const mockIFrame = document.createElement('iframe'); + (liveboardEmbed as any).iFrame = mockIFrame; + + // Access the fullHeightClient and spy on its setIFrameHeight method + const fullHeightClient = (liveboardEmbed as any).fullHeightClient; + const spySetIFrameHeight = jest.spyOn(fullHeightClient, 'setIFrameHeight' as any); + + // Simulate the route change event handler + fullHeightClient.setIframeHeightForNonEmbedLiveboard({ + data: { currentPath: '/some/other/path/' }, + type: 'Route', + }); + + expect(spySetIFrameHeight).toHaveBeenCalled(); + }); + + test('should not call setIFrameHeight if currentPath starts with "/embed/viz/" (AppEmbed)', () => { + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + } as AppViewConfig); + + appEmbed.render(); + + // Access the fullHeightClient and spy on its setIFrameHeight method + const fullHeightClient = (appEmbed as any).fullHeightClient; + const spySetIFrameHeight = jest.spyOn(fullHeightClient, 'setIFrameHeight' as any); + + // Simulate the route change event handler + fullHeightClient.setIframeHeightForNonEmbedLiveboard({ + data: { currentPath: '/embed/viz/' }, + type: 'Route', + }); + + expect(spySetIFrameHeight).not.toHaveBeenCalled(); + }); + + test('should not call setIFrameHeight if currentPath starts with "/embed/insights/viz/" (AppEmbed)', () => { + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + } as AppViewConfig); + + appEmbed.render(); + + // Access the fullHeightClient and spy on its setIFrameHeight method + const fullHeightClient = (appEmbed as any).fullHeightClient; + const spySetIFrameHeight = jest.spyOn(fullHeightClient, 'setIFrameHeight' as any); + + // Simulate the route change event handler + fullHeightClient.setIframeHeightForNonEmbedLiveboard({ + data: { currentPath: '/embed/insights/viz/' }, + type: 'Route', + }); + + expect(spySetIFrameHeight).not.toHaveBeenCalled(); + }); + + test('should call setIFrameHeight if currentPath starts with "/some/other/path/" (AppEmbed)', () => { + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + } as AppViewConfig); + + appEmbed.render(); + + // Set up the iframe + const mockIFrame = document.createElement('iframe'); + (appEmbed as any).iFrame = mockIFrame; + + // Access the fullHeightClient and spy on its setIFrameHeight method + const fullHeightClient = (appEmbed as any).fullHeightClient; + const spySetIFrameHeight = jest.spyOn(fullHeightClient, 'setIFrameHeight' as any); + + // Simulate the route change event handler + fullHeightClient.setIframeHeightForNonEmbedLiveboard({ + data: { currentPath: '/some/other/path/' }, + type: 'Route', + }); + + expect(spySetIFrameHeight).toHaveBeenCalled(); + }); + }); + + describe('LazyLoadingForFullHeight functionality', () => { + let mockIFrame: HTMLIFrameElement; + + beforeEach(() => { + mockIFrame = document.createElement('iframe'); + mockIFrame.getBoundingClientRect = jest.fn().mockReturnValue({ + top: 100, + left: 150, + bottom: 600, + right: 800, + width: 650, + height: 500, + }); + jest.spyOn(document, 'createElement').mockImplementation((tagName) => { + if (tagName === 'iframe') { + return mockIFrame; + } + return document.createElement(tagName); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('LiveboardEmbed lazy loading', () => { + test('should set lazyLoadingMargin parameter when provided', async () => { + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId, + fullHeight: true, + lazyLoadingForFullHeight: true, + lazyLoadingMargin: '100px 0px', + } as LiveboardViewConfig); + + await liveboardEmbed.render(); + + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).toContain('isLazyLoadingForEmbedEnabled=true'); + expect(iframeSrc).toContain('isFullHeightPinboard=true'); + expect(iframeSrc).toContain('rootMarginForLazyLoad=100px%200px'); + }, 100); + }); + + test('should set isLazyLoadingForEmbedEnabled=true when both fullHeight and lazyLoadingForFullHeight are enabled', async () => { + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as LiveboardViewConfig); + + await liveboardEmbed.render(); + + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).toContain('isLazyLoadingForEmbedEnabled=true'); + expect(iframeSrc).toContain('isFullHeightPinboard=true'); + }, 100); + }); + + test('should not set lazyLoadingForEmbed when lazyLoadingForFullHeight is enabled but fullHeight is false', async () => { + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId, + fullHeight: false, + lazyLoadingForFullHeight: true, + } as LiveboardViewConfig); + + await liveboardEmbed.render(); + + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled=true'); + expect(iframeSrc).not.toContain('isFullHeightPinboard=true'); + }, 100); + }); + + test('should not set isLazyLoadingForEmbedEnabled when fullHeight is true but lazyLoadingForFullHeight is false', async () => { + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId, + fullHeight: true, + lazyLoadingForFullHeight: false, + } as LiveboardViewConfig); + + await liveboardEmbed.render(); + + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled=true'); + expect(iframeSrc).toContain('isFullHeightPinboard=true'); + }, 100); + }); + + test('should register event handlers to adjust iframe height', async () => { + const onSpy = jest.spyOn(LiveboardEmbed.prototype, 'on'); + + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as LiveboardViewConfig); + + await liveboardEmbed.render(); + + 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()); + }, 100); + }); + + test('should send correct visible data when RequestVisibleEmbedCoordinates is triggered', async () => { + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as LiveboardViewConfig); + + const mockTrigger = jest.spyOn(liveboardEmbed, 'trigger'); + + await liveboardEmbed.render(); + + // Access the fullHeightClient and call the method + const fullHeightClient = (liveboardEmbed as any).fullHeightClient; + fullHeightClient.sendFullHeightLazyLoadData(); + + expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { + top: 0, + height: 500, + left: 0, + width: 650, + }); + }); + + test('should calculate correct visible data for partially visible full height element', async () => { + mockIFrame.getBoundingClientRect = jest.fn().mockReturnValue({ + top: -50, + left: -30, + bottom: 700, + right: 1024, + width: 1054, + height: 750, + }); + + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as LiveboardViewConfig); + + const mockTrigger = jest.spyOn(liveboardEmbed, 'trigger'); + + await liveboardEmbed.render(); + + // Access the fullHeightClient and call the method + const fullHeightClient = (liveboardEmbed as any).fullHeightClient; + fullHeightClient.sendFullHeightLazyLoadData(); + + expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { + top: 50, + height: 700, + left: 30, + width: 1024, + }); + }); + + test('should add window event listeners for resize and scroll when fullHeight and lazyLoadingForFullHeight are enabled', async () => { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as LiveboardViewConfig); + + await liveboardEmbed.render(); + + // Wait for the post-render events to be registered + await executeAfterWait(() => { + expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything()); + expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything(), true); + }, 100); + + addEventListenerSpy.mockRestore(); + }); + + test('should remove window event listeners on destroy when fullHeight and lazyLoadingForFullHeight are enabled', async () => { + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as LiveboardViewConfig); + + await liveboardEmbed.render(); + liveboardEmbed.destroy(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything()); + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything()); + + removeEventListenerSpy.mockRestore(); + }); + + test('should handle RequestVisibleEmbedCoordinates event and respond with correct data', async () => { + // Mock the iframe element + mockIFrame.getBoundingClientRect = jest.fn().mockReturnValue({ + top: 100, + left: 150, + bottom: 600, + right: 800, + width: 650, + height: 500, + }); + Object.defineProperty(mockIFrame, 'scrollHeight', { value: 500 }); + + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + ...defaultViewConfig, + liveboardId, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as LiveboardViewConfig); + + // Set the iframe before render + (liveboardEmbed as any).iFrame = mockIFrame; + + await liveboardEmbed.render(); + + // Create a mock responder function + const mockResponder = jest.fn(); + + // Access the fullHeightClient and call the handler directly + const fullHeightClient = (liveboardEmbed as any).fullHeightClient; + fullHeightClient.requestVisibleEmbedCoordinatesHandler({}, mockResponder); + + // Verify the responder was called with the correct data + expect(mockResponder).toHaveBeenCalledWith({ + type: EmbedEvent.RequestVisibleEmbedCoordinates, + data: { + top: 0, + height: 500, + left: 0, + width: 650, + }, + }); + }); + }); + + describe('AppEmbed lazy loading', () => { + test('should set lazyLoadingMargin parameter when provided', async () => { + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + lazyLoadingForFullHeight: true, + lazyLoadingMargin: '100px 0px', + } as AppViewConfig); + + await appEmbed.render(); + + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).toContain('isLazyLoadingForEmbedEnabled=true'); + expect(iframeSrc).toContain('isFullHeightPinboard=true'); + expect(iframeSrc).toContain('rootMarginForLazyLoad=100px%200px'); + }, 100); + }); + + test('should set isLazyLoadingForEmbedEnabled=true when both fullHeight and lazyLoadingForFullHeight are enabled', async () => { + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as AppViewConfig); + + await appEmbed.render(); + + // Wait for iframe initialization and URL parameters to be set + await executeAfterWait(() => { + const iframeSrc = appEmbed.getIFrameSrc(); + expect(iframeSrc).toContain('isLazyLoadingForEmbedEnabled=true'); + expect(iframeSrc).toContain('isFullHeightPinboard=true'); + }, 100); + }); + + test('should not set lazyLoadingForEmbed when lazyLoadingForFullHeight is enabled but fullHeight is false', async () => { + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: false, + lazyLoadingForFullHeight: true, + } as AppViewConfig); + + // Wait for render to complete + await appEmbed.render(); + + // Wait for iframe initialization and URL parameters to be set + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled=true'); + expect(iframeSrc).not.toContain('isFullHeightPinboard=true'); + }, 100); + }); + + test('should not set isLazyLoadingForEmbedEnabled when fullHeight is true but lazyLoadingForFullHeight is false', async () => { + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + lazyLoadingForFullHeight: false, + } as AppViewConfig); + + // Wait for render to complete + await appEmbed.render(); + + // Wait for iframe initialization and URL parameters to be set + await executeAfterWait(() => { + const iframeSrc = getIFrameSrc(); + expect(iframeSrc).not.toContain('isLazyLoadingForEmbedEnabled=true'); + expect(iframeSrc).toContain('isFullHeightPinboard=true'); + }, 100); + }); + + test('should register RequestFullHeightLazyLoadData event handler when fullHeight is enabled', async () => { + const onSpy = jest.spyOn(AppEmbed.prototype, 'on'); + + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + } as AppViewConfig); + + await appEmbed.render(); + + expect(onSpy).toHaveBeenCalledWith(EmbedEvent.RequestVisibleEmbedCoordinates, expect.any(Function)); + + onSpy.mockRestore(); + }); + + test('should send correct visible data when RequestFullHeightLazyLoadData is triggered', async () => { + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as AppViewConfig); + + const mockTrigger = jest.spyOn(appEmbed, 'trigger'); + + await appEmbed.render(); + + // Access the fullHeightClient and call the method + const fullHeightClient = (appEmbed as any).fullHeightClient; + fullHeightClient.sendFullHeightLazyLoadData(); + + expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { + top: 0, + height: 500, + left: 0, + width: 650, + }); + }); + + test('should calculate correct visible data for partially visible full height element', async () => { + // Mock iframe partially clipped from top and left + mockIFrame.getBoundingClientRect = jest.fn().mockReturnValue({ + top: -50, + left: -30, + bottom: 700, + right: 1024, + width: 1054, + height: 750, + }); + + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as AppViewConfig); + + const mockTrigger = jest.spyOn(appEmbed, 'trigger'); + + await appEmbed.render(); + + // Access the fullHeightClient and call the method + const fullHeightClient = (appEmbed as any).fullHeightClient; + fullHeightClient.sendFullHeightLazyLoadData(); + + expect(mockTrigger).toHaveBeenCalledWith(HostEvent.VisibleEmbedCoordinates, { + top: 50, // 50px clipped from top + height: 700, // visible height (from 0 to 700) + left: 30, // 30px clipped from left + width: 1024, // visible width (from 0 to 1024) + }); + }); + + test('should add window event listeners for resize and scroll when fullHeight and lazyLoadingForFullHeight are enabled', async () => { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as AppViewConfig); + + await appEmbed.render(); + + // Wait for the post-render events to be registered + await executeAfterWait(() => { + expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); + expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function), true); + }, 100); + + addEventListenerSpy.mockRestore(); + }); + + test('should remove window event listeners on destroy when fullHeight and lazyLoadingForFullHeight are enabled', async () => { + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as AppViewConfig); + + await appEmbed.render(); + appEmbed.destroy(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); + + removeEventListenerSpy.mockRestore(); + }); + + test('should handle RequestVisibleEmbedCoordinates event and respond with correct data', async () => { + // Mock the iframe element + mockIFrame.getBoundingClientRect = jest.fn().mockReturnValue({ + top: 100, + left: 150, + bottom: 600, + right: 800, + width: 650, + height: 500, + }); + Object.defineProperty(mockIFrame, 'scrollHeight', { value: 500 }); + + const appEmbed = new AppEmbed(getRootEl(), { + ...defaultViewConfig, + fullHeight: true, + lazyLoadingForFullHeight: true, + } as AppViewConfig); + + // Set the iframe before render + (appEmbed as any).iFrame = mockIFrame; + + await appEmbed.render(); + + // Create a mock responder function + const mockResponder = jest.fn(); + + // Access the fullHeightClient and call the handler directly + const fullHeightClient = (appEmbed as any).fullHeightClient; + fullHeightClient.requestVisibleEmbedCoordinatesHandler({}, mockResponder); + + // Verify the responder was called with the correct data + expect(mockResponder).toHaveBeenCalledWith({ + type: EmbedEvent.RequestVisibleEmbedCoordinates, + data: { + top: 0, + height: 500, + left: 0, + width: 650, + }, + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/full-height.ts b/src/full-height.ts new file mode 100644 index 00000000..7be3882b --- /dev/null +++ b/src/full-height.ts @@ -0,0 +1,148 @@ +import { BaseViewConfig, EmbedEvent, FullHeightViewConfig, HostEvent, MessageCallback, MessagePayload, Param } from "./types"; +import { calculateElementCenter, calculateVisibleElementData, getCssDimension } from "./utils"; +import { logger } from "./utils/logger"; + +interface FullHeightConfig { + getIframe: () => HTMLIFrameElement; + onEmbedEvent: (event: EmbedEvent, callback: MessageCallback) => void; + getViewConfig: () => FullHeightViewConfig & BaseViewConfig; + triggerHostEvent: (event: HostEvent, data: any) => Promise; +} + + +/** + * This class encapsulates the logic for handling full-height embeds. + * It is designed to be reused by different embed components like AppEmbed + * and LiveboardEmbed. + * + * @hidden + */ +export class FullHeight { + + private onEmbedMessage: FullHeightConfig['onEmbedEvent']; + private getViewConfig: FullHeightConfig['getViewConfig']; + private triggerHostEvent: FullHeightConfig['triggerHostEvent']; + private getIframe: FullHeightConfig['getIframe']; + private defaultHeight: number = 500; + + constructor(fullHeightConfig: FullHeightConfig) { + this.getIframe = fullHeightConfig.getIframe; + this.onEmbedMessage = fullHeightConfig.onEmbedEvent; + this.getViewConfig = fullHeightConfig.getViewConfig; + this.triggerHostEvent = fullHeightConfig.triggerHostEvent; + this.defaultHeight = this.getViewConfig().defaultHeight || this.defaultHeight; + } + /** + * Sets the height of the iframe + * @param height The height in pixels + */ + private setIFrameHeight(height: number | string): void { + this.getIframe().style.height = getCssDimension(height); + } + /** + * 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 sendFullHeightLazyLoadData = () => { + const data = calculateVisibleElementData(this.getIframe()); + this.triggerHostEvent(HostEvent.VisibleEmbedCoordinates, data); + } + private setIframeHeightForNonEmbedLiveboard = (data: MessagePayload) => { + const viewConfig = this.getViewConfig(); + const { height: frameHeight } = 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 embedIframeCenter = (data: MessagePayload, responder: any) => { + const obj = calculateElementCenter(this.getIframe()); + responder({ type: EmbedEvent.EmbedIframeCenter, data: obj }); + }; + + + /** + * 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.getIframe()); + responder({ type: EmbedEvent.RequestVisibleEmbedCoordinates, data: visibleCoordinatesData }); + } + private registerLazyLoadEvents() { + const viewConfig = this.getViewConfig(); + if (viewConfig.fullHeight && viewConfig.lazyLoadingForFullHeight) { + // TODO: Use passive: true, install modernizr to check for passive + window.addEventListener('resize', this.sendFullHeightLazyLoadData); + window.addEventListener('scroll', this.sendFullHeightLazyLoadData, true); + } + } + + private unregisterLazyLoadEvents() { + const viewConfig = this.getViewConfig(); + if (viewConfig.fullHeight && viewConfig.lazyLoadingForFullHeight) { + window.removeEventListener('resize', this.sendFullHeightLazyLoadData); + window.removeEventListener('scroll', this.sendFullHeightLazyLoadData); + } + } + + /** + * Initializes the full-height events. + */ + init() { + this.registerLazyLoadEvents(); + this.onEmbedMessage(EmbedEvent.RouteChange, this.setIframeHeightForNonEmbedLiveboard); + this.onEmbedMessage(EmbedEvent.EmbedHeight, this.updateIFrameHeight); + this.onEmbedMessage(EmbedEvent.EmbedIframeCenter, this.embedIframeCenter); + this.onEmbedMessage(EmbedEvent.RequestVisibleEmbedCoordinates, this.requestVisibleEmbedCoordinatesHandler); + } + + /** + * Sets the parameters for the full-height embed. + * @param params The parameters to set + */ + getParamsForFullHeight(params: any) { + const viewConfig = this.getViewConfig(); + const { lazyLoadingForFullHeight, lazyLoadingMargin } = viewConfig; + const paramsForFullHeight = { ...params, [Param.fullHeight]: true }; + if (lazyLoadingForFullHeight) { + paramsForFullHeight[Param.IsLazyLoadingForEmbedEnabled] = true; + paramsForFullHeight[Param.RootMarginForLazyLoad] = lazyLoadingMargin; + } + return paramsForFullHeight; + } + + /** + * Cleans up the full-height events. + */ + cleanup() { + this.unregisterLazyLoadEvents(); + } + +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 92ed14c2..0f3c70a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,7 +14,7 @@ import type { SessionInterface } from './utils/graphql/answerService/answerServi * the embedded app * @group Authentication / Init */ -// eslint-disable-next-line no-shadow + export enum AuthType { /** * No authentication on the SDK. Pass-through to the embedded App. Alias for @@ -231,12 +231,12 @@ export type DOMSelector = string | HTMLElement; * Use {@link CustomCssVariables} or css rules. */ export interface customCssInterface { - /** - * The custom css variables, which can be set. - * The variables are available in the {@link CustomCssVariables} - * interface. For more information, see - * link:https://developers.thoughtspot.com/docs/css-variables-reference[CSS variable reference]. - */ + /** + * The custom css variables, which can be set. + * The variables are available in the {@link CustomCssVariables} + * interface. For more information, see + * link:https://developers.thoughtspot.com/docs/css-variables-reference[CSS variable reference]. + */ variables?: CustomCssVariables; /** * Can be used to define a custom font face @@ -262,7 +262,7 @@ export interface customCssInterface { * }; * ``` */ - // eslint-disable-next-line camelcase + rules_UNSTABLE?: { [selector: string]: { [declaration: string]: string; @@ -648,7 +648,7 @@ export interface EmbedConfig { * ``` * @version SDK 1.37.0 | ThoughtSpot: 10.8.0.cl */ - customVariablesForThirdPartyTools?: Record< string, any >; + customVariablesForThirdPartyTools?: Record; disablePreauthCache?: boolean; @@ -669,7 +669,7 @@ export interface EmbedConfig { } // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface LayoutConfig {} +export interface LayoutConfig { } /** * Embedded iframe configuration @@ -731,7 +731,7 @@ export interface BaseViewConfig { /** * @hidden */ - // eslint-disable-next-line camelcase + styleSheet__unstable?: string; /** * The list of actions to disable from the primary menu, more menu @@ -931,7 +931,7 @@ export interface BaseViewConfig { * ``` * @version SDK: 1.31.2 | ThoughtSpot: 10.0.0.cl */ - // eslint-disable-next-line camelcase + enableV2Shell_experimental?: boolean; /** * For internal tracking of the embed component type. @@ -1347,7 +1347,8 @@ export interface LiveboardAppEmbedViewConfig { */ isLiveboardCompactHeaderEnabled?: boolean; /** - * This flag can be used to show or hide the Liveboard verified icon in the compact header. + * This flag can be used to show or hide the Liveboard verified icon in the compact + * header. * * Supported embed types: `AppEmbed`, `LiveboardEmbed` * @version SDK: 1.35.0 | ThoughtSpot:10.4.0.cl @@ -1379,7 +1380,8 @@ export interface LiveboardAppEmbedViewConfig { */ hideIrrelevantChipsInLiveboardTabs?: boolean; /** - * This flag can be used to show or hide the re-verify banner on the Liveboard compact header + * This flag can be used to show or hide the re-verify banner on the Liveboard + * compact header * * Supported embed types: `AppEmbed`, `LiveboardEmbed` * @version SDK: 1.35.0 | ThoughtSpot:10.4.0.cl @@ -1410,25 +1412,25 @@ export interface LiveboardAppEmbedViewConfig { * ``` */ enableAskSage?: boolean; - /** - * This flag is used to show or hide checkboxes for including or excluding - * the cover and filters pages in the Liveboard PDF. - * - * Supported embed types: `AppEmbed`, `LiveboardEmbed` - * @version SDK: 1.40.0 | ThoughtSpot:10.8.0.cl - * @example - * ```js - * // Replace with embed component name. For example, AppEmbed or LiveboardEmbed - * const embed = new ('#tsEmbed', { - * ... // other embed view config - * coverAndFilterOptionInPDF: false, - * }) - * ``` - */ + /** + * This flag is used to show or hide checkboxes for including or excluding + * the cover and filters pages in the Liveboard PDF. + * + * Supported embed types: `AppEmbed`, `LiveboardEmbed` + * @version SDK: 1.40.0 | ThoughtSpot:10.8.0.cl + * @example + * ```js + * // Replace with embed component name. For example, AppEmbed or LiveboardEmbed + * const embed = new ('#tsEmbed', { + * ... // other embed view config + * coverAndFilterOptionInPDF: false, + * }) + * ``` + */ coverAndFilterOptionInPDF?: boolean; } -export interface AllEmbedViewConfig extends BaseViewConfig, SearchLiveboardCommonViewConfig, HomePageConfig, LiveboardAppEmbedViewConfig {} +export interface AllEmbedViewConfig extends BaseViewConfig, SearchLiveboardCommonViewConfig, HomePageConfig, LiveboardAppEmbedViewConfig { } /** * MessagePayload: Embed event payload: message type, data and status (start/end) @@ -1489,7 +1491,7 @@ export type QueryParams = { /** * A map of the supported runtime filter operations */ -// eslint-disable-next-line no-shadow + export enum RuntimeFilterOp { /** * Equals @@ -1560,7 +1562,7 @@ export enum RuntimeFilterOp { * `modularHomeExperience` to `true` (available as Early Access feature in 9.12.5.cl). * @version SDK: 1.28.0 | ThoughtSpot: 9.12.5.cl, 10.1.0.sw */ -// eslint-disable-next-line no-shadow + export enum HomepageModule { /** * Search bar @@ -1593,7 +1595,7 @@ export enum HomepageModule { * **Note**: This option is applicable to full app embedding only. * @version SDK: 1.38.0 | ThoughtSpot: 10.9.0.cl */ -// eslint-disable-next-line no-shadow + export enum ListPageColumns { /** * Favourite @@ -1691,7 +1693,7 @@ export interface RuntimeParameter { * ``` * @group Events */ -// eslint-disable-next-line no-shadow + export enum EmbedEvent { /** * Rendering has initialized. @@ -2682,25 +2684,25 @@ export enum EmbedEvent { * ``` * @version SDK: 1.37.0 | ThoughtSpot: 10.8.0.cl */ - TableVizRendered = 'TableVizRendered', - /** - * Emitted when the liveboard is created from pin modal or Liveboard list page. - * You can use this event as a hook to trigger - * other events on liveboard creation. - * - * ```js - * liveboardEmbed.on(EmbedEvent.CreateLiveboard, (payload) => { - * console.log('payload', payload); - * }) - *``` - * @version SDK : 1.37.0 | ThoughtSpot: 10.8.0.cl - */ + TableVizRendered = 'TableVizRendered', + /** + * Emitted when the liveboard is created from pin modal or Liveboard list page. + * You can use this event as a hook to trigger + * other events on liveboard creation. + * + * ```js + * liveboardEmbed.on(EmbedEvent.CreateLiveboard, (payload) => { + * console.log('payload', payload); + * }) + *``` + * @version SDK : 1.37.0 | ThoughtSpot: 10.8.0.cl + */ CreateLiveboard = 'createLiveboard', /** * Emitted when a user creates a Model. * @version SDK : 1.37.0 | ThoughtSpot: 10.8.0.cl */ - CreateModel = 'createModel', + CreateModel = 'createModel', /** * @hidden * Emitted when a user exits present mode. @@ -2845,7 +2847,7 @@ export enum EmbedEvent { * ``` * @group Events */ -// eslint-disable-next-line no-shadow + export enum HostEvent { /** * Triggers a search operation with the search tokens specified in @@ -3874,7 +3876,7 @@ export enum HostEvent { *``` * @version SDK: 1.36.0 | ThoughtSpot: 10.6.0.cl */ - InfoSuccess = 'InfoSuccess', + InfoSuccess = 'InfoSuccess', /** * Trigger the save action for an Answer. * To programmatically save an answer without opening the @@ -4019,7 +4021,7 @@ export enum HostEvent { * The different visual modes that the data sources panel within * search could appear in, such as hidden, collapsed, or expanded. */ -// eslint-disable-next-line no-shadow + export enum DataSourceVisualMode { /** * The data source panel is hidden. @@ -4039,7 +4041,7 @@ export enum DataSourceVisualMode { * The query params passed down to the embedded ThoughtSpot app * containing configuration and/or visual information. */ -// eslint-disable-next-line no-shadow + export enum Param { EmbedApp = 'embedApp', DataSources = 'dataSources', @@ -4188,7 +4190,7 @@ export enum Param { * ``` * See also link:https://developers.thoughtspot.com/docs/actions[Action IDs in the SDK] */ -// eslint-disable-next-line no-shadow + export enum Action { /** * The **Save** action on an Answer or Liveboard. @@ -5445,15 +5447,15 @@ export interface ColumnValue { [key: string]: any; }; value: - | string - | number - | boolean - | { - v: { - s: number; - e: number; - }; - }; + | string + | number + | boolean + | { + v: { + s: number; + e: number; + }; + }; } export interface VizPoint { @@ -5583,3 +5585,89 @@ export interface DefaultAppInitData { customVariablesForThirdPartyTools: Record; hiddenListColumns: ListPageColumns[]; } + +export interface FullHeightViewConfig extends BaseViewConfig { + /** + * 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 + * @default 500 + * @example + * ```js + * const embed = new ('#embed', { + * ... // other liveboard view config + * fullHeight: true, + * defaultHeight: 600, + * }); + * ``` + */ + defaultHeight?: number; + /** + * 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. + * + * @version SDK: 1.1.0 | ThoughtSpot: ts7.may.cl, 7.2.1 + * + * Supported embed types: `LiveboardEmbed` , `AppEmbed` + * @example + * ```js + * const embed = new ('#embed', { + * ... // other liveboard view config + * fullHeight: true, + * }); + * ``` + */ + fullHeight?: boolean; + /** + * This flag is used to enable the full height lazy load data. + * + * Supported embed types: `LiveboardEmbed` , `AppEmbed` + * @example + * ```js + * const embed = new ('#embed-container', { + * // ...other options + * fullHeight: true, + * lazyLoadingForFullHeight: true, + * }) + * ``` + * + * @type {boolean} + * @default false + * @version SDK: 1.40.0 | ThoughtSpot:10.12.0.cl + */ + lazyLoadingForFullHeight?: boolean; + /** + * The margin to be used for lazy loading. + * + * The default for this is 25%, which means we start loading when the visualization + * is within 25% of the viewport boundary. For example, if the viewport has 1000px + * height, we start loading when the visualization is within 250px of becoming + * visible. + * + * The format is similar to CSS margin. + * + * @example + * ```js + * const embed = new ('#embed-container', { + * // ...other options + * fullHeight: true, + * lazyLoadingForFullHeight: true, + * // Using 0px, the visualization will be only started loading when its visible in the viewport. + * lazyLoadingMargin: '0px', + * }) + * ``` + * @type {string} + * @version SDK: 1.40.0 | ThoughtSpot:10.12.0.cl + */ + lazyLoadingMargin?: string; +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index 86c8c27f..60bca74c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -489,3 +489,38 @@ export const calculateVisibleElementData = (element: HTMLElement) => { return data; } + +/** + * Calculates the element center for the current visible viewPort + * of element using Scroll position of Host App, offsetTop for element + * in Host app. ViewPort height of the tab. + * @returns element Center in visible viewport, + * Iframe height, + * View port 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, + }; +}