From dd9724daae3bda4ee18db5f8010b9bfffcbcf66d Mon Sep 17 00:00:00 2001 From: Gabriel Torres Ruiz Date: Fri, 10 Apr 2026 12:54:59 -0300 Subject: [PATCH] fix(ag-grid): jpeg export of ag-grid tables (#38781) (cherry picked from commit 69c8eef78e98ef5f5ccd360f114877be58eb84ee) --- .../ThemedAgGridReact.test.tsx | 45 +- .../components/ThemedAgGridReact/index.tsx | 49 +- .../superset-ui-core/src/components/index.ts | 1 + .../src/utils/downloadAsImage.test.ts | 685 ++++++++++++++++++ .../src/utils/downloadAsImage.tsx | 187 ++++- 5 files changed, 956 insertions(+), 11 deletions(-) create mode 100644 superset-frontend/src/utils/downloadAsImage.test.ts diff --git a/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/ThemedAgGridReact.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/ThemedAgGridReact.test.tsx index 4d34e022edf..42497b1a2bf 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/ThemedAgGridReact.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/ThemedAgGridReact.test.tsx @@ -158,11 +158,13 @@ test('passes all props through to AgGridReact', () => { />, ); + // onGridReady and onFirstDataRendered are intercepted by the component to expose + // the grid API on the container element; the wrapped function is passed instead. expect(AgGridReact).toHaveBeenCalledWith( expect.objectContaining({ rowData: mockRowData, columnDefs: mockColumnDefs, - onGridReady, + onGridReady: expect.any(Function), onCellClicked, pagination: true, paginationPageSize: 10, @@ -171,6 +173,47 @@ test('passes all props through to AgGridReact', () => { ); }); +test('onGridReady wrapper calls user callback and exposes api on container', () => { + const onGridReady = jest.fn(); + + render( + , + ); + + // Retrieve the wrapped handler that was passed to AgGridReact + const lastCall = (AgGridReact as jest.Mock).mock.calls.at(-1)[0]; + const wrappedOnGridReady = lastCall.onGridReady as Function; + + const mockApi = { setGridOption: jest.fn() }; + wrappedOnGridReady({ api: mockApi }); + + // The user-provided callback must be forwarded + expect(onGridReady).toHaveBeenCalledWith({ api: mockApi }); +}); + +test('onFirstDataRendered wrapper calls user callback', () => { + const onFirstDataRendered = jest.fn(); + + render( + , + ); + + const lastCall = (AgGridReact as jest.Mock).mock.calls.at(-1)[0]; + const wrappedOnFirstDataRendered = lastCall.onFirstDataRendered as Function; + + wrappedOnFirstDataRendered({ firstRow: 0 }); + + expect(onFirstDataRendered).toHaveBeenCalledWith({ firstRow: 0 }); +}); + test('applies custom theme colors from Superset theme', () => { const customTheme = { ...supersetTheme, diff --git a/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/index.tsx index 507a6d73b2a..2b027080365 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/ThemedAgGridReact/index.tsx @@ -16,19 +16,28 @@ * specific language governing permissions and limitations * under the License. */ -import { useMemo, forwardRef } from 'react'; +import { useMemo, useRef, useCallback, forwardRef } from 'react'; import { css } from '@emotion/react'; import { AgGridReact, type AgGridReactProps } from 'ag-grid-react'; import { themeQuartz, colorSchemeDark, colorSchemeLight, + type GridApi, + type GridReadyEvent, + type FirstDataRenderedEvent, } from 'ag-grid-community'; import { useTheme, useThemeMode } from '@apache-superset/core/theme'; // Note: With ag-grid v34's new theming API, CSS files are injected automatically // Do NOT import 'ag-grid-community/styles/ag-grid.css' or theme CSS files +// Extends HTMLDivElement with ag-grid state attached to the container for downloadAsImage. +export interface AgGridContainerElement extends HTMLDivElement { + _agGridApi?: GridApi; + _agGridFirstDataRendered?: boolean; +} + export interface ThemedAgGridReactProps extends AgGridReactProps { /** * Optional theme parameter overrides to customize specific ag-grid theme values. @@ -71,9 +80,13 @@ export interface ThemedAgGridReactProps extends AgGridReactProps { export const ThemedAgGridReact = forwardRef< AgGridReact, ThemedAgGridReactProps ->(function ThemedAgGridReact({ themeOverrides, ...props }, ref) { +>(function ThemedAgGridReact( + { themeOverrides, onGridReady, onFirstDataRendered, ...props }, + ref, +) { const theme = useTheme(); const isDarkMode = useThemeMode(); + const containerRef = useRef(null); // Get the appropriate ag-grid theme based on dark/light mode const agGridTheme = useMemo(() => { @@ -140,8 +153,32 @@ export const ThemedAgGridReact = forwardRef< return baseTheme.withParams(finalParams); }, [theme, isDarkMode, themeOverrides]); + // Expose gridApi and first-data-rendered flag on the container for downloadAsImage. + const handleGridReady = useCallback( + (event: GridReadyEvent) => { + if (containerRef.current) { + containerRef.current._agGridFirstDataRendered = false; + containerRef.current._agGridApi = event.api; + } + onGridReady?.(event); + }, + [onGridReady], + ); + + // Mark the container once rows are painted so downloadAsImage can gate on readiness. + const handleFirstDataRendered = useCallback( + (event: FirstDataRenderedEvent) => { + if (containerRef.current) { + containerRef.current._agGridFirstDataRendered = true; + } + onFirstDataRendered?.(event); + }, + [onFirstDataRendered], + ); + return (
- +
); }); diff --git a/superset-frontend/packages/superset-ui-core/src/components/index.ts b/superset-frontend/packages/superset-ui-core/src/components/index.ts index 048504ccfd6..223a49b03fd 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/index.ts @@ -201,6 +201,7 @@ export * from './Result'; export { ThemedAgGridReact, type ThemedAgGridReactProps, + type AgGridContainerElement, setupAGGridModules, defaultModules, } from './ThemedAgGridReact'; diff --git a/superset-frontend/src/utils/downloadAsImage.test.ts b/superset-frontend/src/utils/downloadAsImage.test.ts new file mode 100644 index 00000000000..0959a8eaa22 --- /dev/null +++ b/superset-frontend/src/utils/downloadAsImage.test.ts @@ -0,0 +1,685 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import domToImage from 'dom-to-image-more'; +import { addWarningToast } from 'src/components/MessageToasts/actions'; +import downloadAsImageOptimized, { + waitForStableScrollHeight, +} from './downloadAsImage'; + +jest.mock('dom-to-image-more', () => ({ + __esModule: true, + default: { toJpeg: jest.fn() }, +})); + +jest.mock('src/components/MessageToasts/actions', () => ({ + addWarningToast: jest.fn(), +})); + +jest.mock('@apache-superset/core/translation', () => ({ + t: (str: string) => str, +})); + +const mockToJpeg = domToImage.toJpeg as jest.Mock; +const mockAddWarningToast = addWarningToast as jest.Mock; + +// document.fonts.ready is not implemented in jsdom; provide a resolved promise +Object.defineProperty(document, 'fonts', { + value: { ready: Promise.resolve() }, + configurable: true, +}); + +// Build a synthetic React event that resolves `currentTarget.closest()` to a given element +function syntheticEventFor(el: Element) { + return { currentTarget: { closest: () => el } } as any; +} + +// Build and attach an ag-grid DOM structure; returns cleanup function +function buildAgGridElement() { + const container = document.createElement('div'); + const agContainer = document.createElement('div'); + agContainer.setAttribute('data-themed-ag-grid', 'true'); + const agRootWrapper = document.createElement('div'); + agRootWrapper.className = 'ag-root-wrapper'; + agContainer.appendChild(agRootWrapper); + container.appendChild(agContainer); + document.body.appendChild(container); + return { + container, + agContainer, + agRootWrapper, + cleanup: () => document.body.removeChild(container), + }; +} + +// Attach a mock GridApi and set the first-data-rendered flag on the container +function attachMockApi( + agContainer: HTMLElement, + { firstDataRendered = true } = {}, +) { + const api = { setGridOption: jest.fn() }; + (agContainer as any)._agGridApi = api; + (agContainer as any)._agGridFirstDataRendered = firstDataRendered; + return api; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockToJpeg.mockResolvedValue('data:image/jpeg;base64,test'); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +test('waitForStableScrollHeight resolves after 2 consecutive stable scrollHeight readings', async () => { + jest.useFakeTimers(); + const el = document.createElement('div'); + Object.defineProperty(el, 'scrollHeight', { + get: () => 100, + configurable: true, + }); + + const promise = waitForStableScrollHeight(el); + await jest.runAllTimersAsync(); + await expect(promise).resolves.toBeUndefined(); + + jest.useRealTimers(); +}); + +test('waitForStableScrollHeight respects a custom minStablePolls', async () => { + jest.useFakeTimers(); + const el = document.createElement('div'); + Object.defineProperty(el, 'scrollHeight', { + get: () => 100, + configurable: true, + }); + + // With minStablePolls=5 the promise must not resolve after just 2 polls. + const promise = waitForStableScrollHeight(el, 5000, 5); + jest.advanceTimersByTime(300); + let resolved = false; + promise.then(() => { + resolved = true; + }); + // Flush microtasks so the .then() above has a chance to run if resolved + await Promise.resolve(); + expect(resolved).toBe(false); + + // Now run the remaining polls (2 more stable polls → total 5) and confirm resolution. + jest.advanceTimersByTime(300); + await expect(promise).resolves.toBeUndefined(); + + jest.useRealTimers(); +}); + +test('waitForStableScrollHeight resets stable count when height changes mid-poll', async () => { + jest.useFakeTimers(); + const el = document.createElement('div'); + let height = 100; + Object.defineProperty(el, 'scrollHeight', { + get: () => height, + configurable: true, + }); + + const promise = waitForStableScrollHeight(el); + // Poll 1: height is 100, stableCount becomes 1 + jest.advanceTimersByTime(100); + // Height changes — stable counter must reset + height = 200; + // Run until new height stabilises (2 consecutive 100 ms polls) + jest.advanceTimersByTime(300); + await expect(promise).resolves.toBeUndefined(); + + jest.useRealTimers(); +}); + +test('waitForStableScrollHeight resolves after maxMs even if height never stabilises', async () => { + jest.useFakeTimers(); + const el = document.createElement('div'); + let height = 0; + Object.defineProperty(el, 'scrollHeight', { + // Always increments so stableFrames never reaches 4 + get: () => { + height += 1; + return height; + }, + configurable: true, + }); + + const promise = waitForStableScrollHeight(el, 200); + jest.advanceTimersByTime(400); // past the 200 ms deadline + await expect(promise).resolves.toBeUndefined(); + + jest.useRealTimers(); +}); + +test('waitForStableScrollHeight resolves if scrollHeight throws (element removed from DOM)', async () => { + jest.useFakeTimers(); + const el = document.createElement('div'); + let shouldThrow = false; + Object.defineProperty(el, 'scrollHeight', { + get: () => { + if (shouldThrow) throw new Error('element detached'); + return 100; + }, + configurable: true, + }); + + const promise = waitForStableScrollHeight(el); + jest.advanceTimersByTime(100); // poll 1: stable, stableCount = 1 + shouldThrow = true; // simulate DOM removal + jest.advanceTimersByTime(100); // poll 2: throws → resolves immediately + await expect(promise).resolves.toBeUndefined(); + + jest.useRealTimers(); +}); + +test('shows warning toast when element is not found', async () => { + const handler = downloadAsImageOptimized('div', 'test'); + // closest() returning null simulates a selector that matches nothing + await handler({ currentTarget: { closest: () => null } } as any); + + expect(mockAddWarningToast).toHaveBeenCalledWith( + 'Image download failed, please refresh and try again.', + ); + expect(mockToJpeg).not.toHaveBeenCalled(); +}); + +test('shows "still loading" toast when grid has not yet rendered its first rows', async () => { + const { container, agContainer, cleanup } = buildAgGridElement(); + attachMockApi(agContainer, { firstDataRendered: false }); + + const handler = downloadAsImageOptimized('div', 'My Chart'); + await handler(syntheticEventFor(container)); + + expect(mockAddWarningToast).toHaveBeenCalledWith( + 'The chart is still loading. Please wait a moment and try again.', + ); + expect(mockToJpeg).not.toHaveBeenCalled(); + + cleanup(); +}); + +test('switches to print layout, captures JPEG, and restores normal layout', async () => { + jest.useFakeTimers(); + const { container, agContainer, cleanup } = buildAgGridElement(); + const api = attachMockApi(agContainer); + + const handler = downloadAsImageOptimized('div', 'My Chart'); + const exportPromise = handler(syntheticEventFor(container)); + await jest.runAllTimersAsync(); + await exportPromise; + + expect(api.setGridOption).toHaveBeenCalledWith('domLayout', 'print'); + expect(mockToJpeg).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ quality: 0.95 }), + ); + expect(api.setGridOption).toHaveBeenCalledWith('domLayout', 'normal'); + + cleanup(); + jest.useRealTimers(); +}); + +test('restores normal layout in finally even when image capture throws', async () => { + jest.useFakeTimers(); + mockToJpeg.mockRejectedValue(new Error('capture failed')); + const { container, agContainer, cleanup } = buildAgGridElement(); + const api = attachMockApi(agContainer); + + const handler = downloadAsImageOptimized('div', 'My Chart'); + const exportPromise = handler(syntheticEventFor(container)); + await jest.runAllTimersAsync(); + await exportPromise; + + expect(api.setGridOption).toHaveBeenCalledWith('domLayout', 'normal'); + expect(mockAddWarningToast).toHaveBeenCalledWith( + 'Image download failed, please refresh and try again.', + ); + + cleanup(); + jest.useRealTimers(); +}); + +test('still captures image when _agGridApi is absent (graceful degradation)', async () => { + jest.useFakeTimers(); + const { container, agContainer, cleanup } = buildAgGridElement(); + // No API — only the first-data-rendered flag + (agContainer as any)._agGridFirstDataRendered = true; + + const handler = downloadAsImageOptimized('div', 'My Chart'); + const exportPromise = handler(syntheticEventFor(container)); + await jest.runAllTimersAsync(); + await exportPromise; + + expect(mockToJpeg).toHaveBeenCalled(); + expect(mockAddWarningToast).not.toHaveBeenCalled(); + + cleanup(); + jest.useRealTimers(); +}); + +test('resolves ag-cell min-height to row pixel height when content fits within it', async () => { + jest.useFakeTimers(); + const { container, agContainer, agRootWrapper, cleanup } = + buildAgGridElement(); + attachMockApi(agContainer); + + // Build a row with a cell inside the grid + const row = document.createElement('div'); + row.className = 'ag-row'; + Object.defineProperty(row, 'offsetHeight', { + get: () => 32, + configurable: true, + }); + const cell = document.createElement('div'); + cell.className = 'ag-cell'; + row.appendChild(cell); + agRootWrapper.appendChild(row); + + let capturedMinHeight = ''; + mockToJpeg.mockImplementation(() => { + capturedMinHeight = cell.style.minHeight; + return Promise.resolve('data:image/jpeg;base64,test'); + }); + + const handler = downloadAsImageOptimized('div', 'My Chart'); + const exportPromise = handler(syntheticEventFor(container)); + await jest.runAllTimersAsync(); + await exportPromise; + + // Cell min-height was resolved to the row's pixel height during capture + expect(capturedMinHeight).toBe('32px'); + // Cell min-height was restored after capture + expect(cell.style.minHeight).toBe(''); + + cleanup(); + jest.useRealTimers(); +}); + +test('uses cell scrollHeight when it exceeds row offsetHeight (stale row heights for off-screen rows)', async () => { + jest.useFakeTimers(); + const { container, agContainer, agRootWrapper, cleanup } = + buildAgGridElement(); + attachMockApi(agContainer); + + const row = document.createElement('div'); + row.className = 'ag-row'; + Object.defineProperty(row, 'offsetHeight', { + get: () => 25, + configurable: true, + }); // stale default + const cell = document.createElement('div'); + cell.className = 'ag-cell'; + Object.defineProperty(cell, 'scrollHeight', { + get: () => 120, + configurable: true, + }); // actual content + row.appendChild(cell); + agRootWrapper.appendChild(row); + + let capturedMinHeight = ''; + mockToJpeg.mockImplementation(() => { + capturedMinHeight = cell.style.minHeight; + return Promise.resolve('data:image/jpeg;base64,test'); + }); + + const handler = downloadAsImageOptimized('div', 'My Chart'); + const exportPromise = handler(syntheticEventFor(container)); + await jest.runAllTimersAsync(); + await exportPromise; + + // Uses the larger content height, not the stale row height + expect(capturedMinHeight).toBe('120px'); + expect(cell.style.minHeight).toBe(''); + + cleanup(); + jest.useRealTimers(); +}); + +test('derives image width from getColumnState by summing visible column pixel widths', async () => { + jest.useFakeTimers(); + const { container, agContainer, cleanup } = buildAgGridElement(); + const api = attachMockApi(agContainer); + + // 3 visible columns (200 + 350 + 150 = 700 px) plus one hidden column excluded from sum + (api as any).getColumnState = jest.fn(() => [ + { colId: 'col1', width: 200, hide: false }, + { colId: 'col2', width: 350, hide: false }, + { colId: 'col3', width: 150, hide: false }, + { colId: 'col4', width: 999, hide: true }, + ]); + (api as any).applyColumnState = jest.fn(); + + let capturedWidth: number | undefined; + mockToJpeg.mockImplementation( + (_el: HTMLElement, opts: { width?: number }) => { + capturedWidth = opts.width; + return Promise.resolve('data:image/jpeg;base64,test'); + }, + ); + + const handler = downloadAsImageOptimized('div', 'My Chart'); + const exportPromise = handler(syntheticEventFor(container)); + await jest.runAllTimersAsync(); + await exportPromise; + + // Width passed to toJpeg is the sum of visible column widths, not agRootWrapper.offsetWidth + expect(capturedWidth).toBe(700); + expect((api as any).getColumnState).toHaveBeenCalled(); + + cleanup(); + jest.useRealTimers(); +}); + +test('restores column pixel widths via applyColumnState with flex stripped after print layout', async () => { + jest.useFakeTimers(); + const { container, agContainer, cleanup } = buildAgGridElement(); + const api = attachMockApi(agContainer); + + const savedState = [ + { colId: 'col1', width: 300, flex: 1, hide: false }, + { colId: 'col2', width: 400, flex: 1.5, hide: false }, + ]; + (api as any).getColumnState = jest.fn(() => savedState); + (api as any).applyColumnState = jest.fn(); + + const handler = downloadAsImageOptimized('div', 'My Chart'); + const exportPromise = handler(syntheticEventFor(container)); + await jest.runAllTimersAsync(); + await exportPromise; + + // flex must be stripped (set to null) so pixel width is used, not flex ratio + expect((api as any).applyColumnState).toHaveBeenCalledWith({ + state: [ + { colId: 'col1', width: 300, flex: null }, + { colId: 'col2', width: 400, flex: null }, + ], + applyOrder: false, + }); + + cleanup(); + jest.useRealTimers(); +}); + +test('restores original column state with flex in finally after capture', async () => { + jest.useFakeTimers(); + const { container, agContainer, cleanup } = buildAgGridElement(); + const api = attachMockApi(agContainer); + + const savedState = [ + { colId: 'col1', width: 300, flex: 1, hide: false }, + { colId: 'col2', width: 400, flex: 1.5, hide: false }, + ]; + (api as any).getColumnState = jest.fn(() => savedState); + (api as any).applyColumnState = jest.fn(); + + const handler = downloadAsImageOptimized('div', 'My Chart'); + const exportPromise = handler(syntheticEventFor(container)); + await jest.runAllTimersAsync(); + await exportPromise; + + // Last call must restore the original state (with flex) so the live grid is unaffected + expect((api as any).applyColumnState.mock.calls.at(-1)[0]).toEqual({ + state: savedState, + applyOrder: false, + }); + + cleanup(); + jest.useRealTimers(); +}); + +test('falls back to agRootWrapper.offsetWidth when getColumnState returns no visible columns', async () => { + jest.useFakeTimers(); + const { container, agContainer, agRootWrapper, cleanup } = + buildAgGridElement(); + const api = attachMockApi(agContainer); + + // All columns hidden → visible sum is 0 → fall back to offsetWidth + (api as any).getColumnState = jest.fn(() => [ + { colId: 'col1', width: 500, hide: true }, + ]); + (api as any).applyColumnState = jest.fn(); + + Object.defineProperty(agRootWrapper, 'offsetWidth', { + get: () => 600, + configurable: true, + }); + + let capturedWidth: number | undefined; + mockToJpeg.mockImplementation( + (_el: HTMLElement, opts: { width?: number }) => { + capturedWidth = opts.width; + return Promise.resolve('data:image/jpeg;base64,test'); + }, + ); + + const handler = downloadAsImageOptimized('div', 'My Chart'); + const exportPromise = handler(syntheticEventFor(container)); + await jest.runAllTimersAsync(); + await exportPromise; + + expect(capturedWidth).toBe(600); + + cleanup(); + jest.useRealTimers(); +}); + +test('restores ag-cell styles after capture even when toJpeg throws', async () => { + jest.useFakeTimers(); + mockToJpeg.mockRejectedValue(new Error('capture failed')); + const { container, agContainer, agRootWrapper, cleanup } = + buildAgGridElement(); + attachMockApi(agContainer); + + const row = document.createElement('div'); + row.className = 'ag-row'; + Object.defineProperty(row, 'offsetHeight', { + get: () => 28, + configurable: true, + }); + const cell = document.createElement('div'); + cell.className = 'ag-cell'; + cell.style.minHeight = '100%'; + cell.style.overflow = 'visible'; + row.appendChild(cell); + agRootWrapper.appendChild(row); + + const handler = downloadAsImageOptimized('div', 'My Chart'); + const exportPromise = handler(syntheticEventFor(container)); + await jest.runAllTimersAsync(); + await exportPromise; + + // Styles restored to original values despite capture error + expect(cell.style.minHeight).toBe('100%'); + expect(cell.style.overflow).toBe('visible'); + + cleanup(); + jest.useRealTimers(); +}); + +test('calls resetRowHeights after print layout to force ag-grid to re-measure rows with stale cached heights', async () => { + jest.useFakeTimers(); + const { container, agContainer, cleanup } = buildAgGridElement(); + const api = attachMockApi(agContainer); + (api as any).resetRowHeights = jest.fn(); + + const handler = downloadAsImageOptimized('div', 'My Chart'); + const exportPromise = handler(syntheticEventFor(container)); + await jest.runAllTimersAsync(); + await exportPromise; + + expect(api.setGridOption).toHaveBeenCalledWith('domLayout', 'print'); + expect((api as any).resetRowHeights).toHaveBeenCalled(); + expect(api.setGridOption).toHaveBeenCalledWith('domLayout', 'normal'); + + cleanup(); + jest.useRealTimers(); +}); + +test('does not throw when resetRowHeights is absent from the api', async () => { + jest.useFakeTimers(); + const { container, agContainer, cleanup } = buildAgGridElement(); + attachMockApi(agContainer); // api has only setGridOption, no resetRowHeights + + const handler = downloadAsImageOptimized('div', 'My Chart'); + const exportPromise = handler(syntheticEventFor(container)); + await jest.runAllTimersAsync(); + await expect(exportPromise).resolves.toBeUndefined(); + + cleanup(); + jest.useRealTimers(); +}); + +test('falls through to clone path for dashboard export with a single ag-grid chart', async () => { + const dashboard = document.createElement('div'); + dashboard.className = 'dashboard'; + + const agContainer = document.createElement('div'); + agContainer.setAttribute('data-themed-ag-grid', 'true'); + const agRootWrapper = document.createElement('div'); + agRootWrapper.className = 'ag-root-wrapper'; + agContainer.appendChild(agRootWrapper); + (agContainer as any)._agGridFirstDataRendered = true; + dashboard.appendChild(agContainer); + document.body.appendChild(dashboard); + + const handler = downloadAsImageOptimized('.dashboard', 'My Dashboard', true); + await handler({ currentTarget: {} } as any); + + expect(mockToJpeg).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ quality: 0.95 }), + ); + expect(mockAddWarningToast).not.toHaveBeenCalled(); + + document.body.removeChild(dashboard); +}); + +test('falls through to clone path for dashboard export with multiple ag-grid charts', async () => { + const dashboard = document.createElement('div'); + dashboard.className = 'dashboard'; + + for (let i = 0; i < 2; i += 1) { + const agContainer = document.createElement('div'); + agContainer.setAttribute('data-themed-ag-grid', 'true'); + const agRootWrapper = document.createElement('div'); + agRootWrapper.className = 'ag-root-wrapper'; + agContainer.appendChild(agRootWrapper); + (agContainer as any)._agGridFirstDataRendered = true; + dashboard.appendChild(agContainer); + } + document.body.appendChild(dashboard); + + const handler = downloadAsImageOptimized('.dashboard', 'My Dashboard', true); + await handler({ currentTarget: {} } as any); + + expect(mockToJpeg).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ quality: 0.95 }), + ); + expect(mockAddWarningToast).not.toHaveBeenCalled(); + + document.body.removeChild(dashboard); +}); + +test('captures JPEG for non-ag-grid elements via the clone path', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + + const handler = downloadAsImageOptimized('div', 'Bar Chart'); + await handler(syntheticEventFor(container)); + + expect(mockToJpeg).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ quality: 0.95 }), + ); + expect(mockAddWarningToast).not.toHaveBeenCalled(); + + document.body.removeChild(container); +}); + +test('shows warning toast when clone capture throws', async () => { + mockToJpeg.mockRejectedValue(new Error('clone capture failed')); + const container = document.createElement('div'); + document.body.appendChild(container); + + const handler = downloadAsImageOptimized('div', 'Bar Chart'); + await handler(syntheticEventFor(container)); + + expect(mockAddWarningToast).toHaveBeenCalledWith( + 'Image download failed, please refresh and try again.', + ); + + document.body.removeChild(container); +}); + +test('ag-grid path uses theme colorBgContainer as background', async () => { + jest.useFakeTimers(); + const { container, agContainer, cleanup } = buildAgGridElement(); + attachMockApi(agContainer); + + const theme = { colorBgContainer: '#1a1a2e' } as any; + const handler = downloadAsImageOptimized('div', 'My Chart', false, theme); + const exportPromise = handler(syntheticEventFor(container)); + await jest.runAllTimersAsync(); + await exportPromise; + + expect(mockToJpeg).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ bgcolor: '#1a1a2e' }), + ); + + cleanup(); + jest.useRealTimers(); +}); + +test('ag-grid path falls back to white background when theme is absent', async () => { + jest.useFakeTimers(); + const { container, agContainer, cleanup } = buildAgGridElement(); + attachMockApi(agContainer); + + const handler = downloadAsImageOptimized('div', 'My Chart'); + const exportPromise = handler(syntheticEventFor(container)); + await jest.runAllTimersAsync(); + await exportPromise; + + expect(mockToJpeg).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ bgcolor: undefined }), + ); + + cleanup(); + jest.useRealTimers(); +}); + +test('clone path falls back to white background when theme is absent', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + + const handler = downloadAsImageOptimized('div', 'Bar Chart'); + await handler(syntheticEventFor(container)); + + expect(mockToJpeg).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ bgcolor: undefined }), + ); + + document.body.removeChild(container); +}); diff --git a/superset-frontend/src/utils/downloadAsImage.tsx b/superset-frontend/src/utils/downloadAsImage.tsx index ceea0f47325..cc1b3134e02 100644 --- a/superset-frontend/src/utils/downloadAsImage.tsx +++ b/superset-frontend/src/utils/downloadAsImage.tsx @@ -19,13 +19,17 @@ import { SyntheticEvent } from 'react'; import domToImage from 'dom-to-image-more'; import { kebabCase } from 'lodash'; -// eslint-disable-next-line no-restricted-imports import { t } from '@apache-superset/core/translation'; import { SupersetTheme } from '@apache-superset/core/theme'; import { addWarningToast } from 'src/components/MessageToasts/actions'; +import type { AgGridContainerElement } from '@superset-ui/core/components'; const IMAGE_DOWNLOAD_QUALITY = 0.95; const TRANSPARENT_RGBA = 'transparent'; +const POLL_INTERVAL_MS = 100; + +// Tracks original cell styles to restore after capture +type CellFixup = { el: HTMLElement; minHeight: string; overflow: string }; /** * generate a consistent file stem from a description and date @@ -80,6 +84,9 @@ const CRITICAL_STYLE_PROPERTIES = new Set([ 'table-layout', 'vertical-align', 'text-align', + 'box-sizing', + 'min-height', + 'min-width', ]); const styleCache = new WeakMap(); @@ -262,6 +269,45 @@ const createEnhancedClone = ( return { clone, cleanup }; }; +// Polls until scrollHeight is stable for minStablePolls consecutive intervals or maxMs elapses. +// ag-grid has no "layout settled" event, so polling is the recommended workaround. +export const waitForStableScrollHeight = ( + el: HTMLElement, + maxMs = 5000, + minStablePolls = 2, +): Promise => + new Promise(resolve => { + const deadline = Date.now() + maxMs; + let lastHeight = el.scrollHeight; + let stableCount = 0; + + const poll = () => { + if (Date.now() >= deadline) { + resolve(); + return; + } + try { + const h = el.scrollHeight; + if (h === lastHeight) { + stableCount += 1; + if (stableCount >= minStablePolls) { + resolve(); + return; + } + } else { + stableCount = 0; + lastHeight = h; + } + } catch { + resolve(); // element removed from DOM + return; + } + setTimeout(poll, POLL_INTERVAL_MS); + }; + + setTimeout(poll, POLL_INTERVAL_MS); + }); + export default function downloadAsImageOptimized( selector: string, description: string, @@ -280,6 +326,139 @@ export default function downloadAsImageOptimized( return; } + const filter = (node: Element) => + typeof node.className === 'string' + ? !node.className.includes('mapboxgl-control-container') && + !node.className.includes('header-controls') + : true; + + // Only apply ag-grid path for single-chart captures. + // Skip entirely for dashboard-level exports (selector targets the .dashboard root). + const isDashboardCapture = ( + elementToPrint as HTMLElement + ).classList.contains('dashboard'); + const agContainers = isDashboardCapture + ? [] + : elementToPrint.querySelectorAll('[data-themed-ag-grid]'); + const agContainer = + agContainers.length === 1 + ? (agContainers[0] as AgGridContainerElement) + : null; + const agRootWrapper = agContainer + ? (agContainer.querySelector('.ag-root-wrapper') as HTMLElement | null) + : null; + + if (agContainer && agRootWrapper) { + const api = agContainer._agGridApi; + const isFirstDataRendered = + agContainer._agGridFirstDataRendered === true; + + if (!isFirstDataRendered) { + addWarningToast( + t('The chart is still loading. Please wait a moment and try again.'), + ); + return; + } + + // Capture resolved pixel widths before print layout can re-trigger sizeColumnsToFit. + // sizeColumnsToFit() sets flex (not pixel widths), so after print layout expands the + // container it recalculates column widths wider. We restore with flex: null to force + // pixel widths when calling applyColumnState after the layout switch. + const savedColumnState = api?.getColumnState?.(); + const visibleColumnState = + savedColumnState?.filter(col => !col.hide) ?? []; + const originalWidth = + visibleColumnState.reduce((sum, col) => sum + (col.width ?? 0), 0) || + agRootWrapper.offsetWidth; + + // Chrome SVG foreignObject bug: % min-height resolves against canvas height, + // causing cells to expand to full image height and overlap adjacent rows. + const cellFixups: CellFixup[] = []; + + try { + await document.fonts.ready; + + if (api) { + api.setGridOption('domLayout', 'print'); + + // Wait for ResizeObserver + any triggered sizeColumnsToFit() to settle, + // then restore column widths before measurement. + await new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + + if (visibleColumnState.length > 0) { + api.applyColumnState?.({ + state: visibleColumnState.map(col => ({ + colId: col.colId, + width: col.width, + flex: null, + })), + applyOrder: false, + }); + } + + // Rows never scrolled into view have stale cached heights; remeasure all. + api.resetRowHeights?.(); + + // 5 polls × POLL_INTERVAL_MS = 500 ms; autoHeight rows batch-measure slowly. + await waitForStableScrollHeight(agRootWrapper, 5000, 5); + } + + agRootWrapper.querySelectorAll('.ag-cell').forEach(cell => { + const el = cell as HTMLElement; + const rowHeight = + (el.parentElement as HTMLElement)?.offsetHeight ?? 0; + // scrollHeight catches any cells where resetRowHeights lagged behind. + const minH = Math.max(rowHeight, el.scrollHeight); + cellFixups.push({ + el, + minHeight: el.style.minHeight, + overflow: el.style.overflow, + }); + el.style.minHeight = minH > 0 ? `${minH}px` : '0px'; + el.style.overflow = 'hidden'; + }); + + const imageHeight = agRootWrapper.scrollHeight; + + const dataUrl = await domToImage.toJpeg(agRootWrapper, { + bgcolor: theme?.colorBgContainer, + filter, + quality: IMAGE_DOWNLOAD_QUALITY, + height: imageHeight, + width: originalWidth, + cacheBust: true, + }); + + const link = document.createElement('a'); + link.download = `${generateFileStem(description)}.jpg`; + link.href = dataUrl; + link.click(); + } catch (error) { + console.error('Creating image failed', error); + addWarningToast( + t('Image download failed, please refresh and try again.'), + ); + } finally { + cellFixups.forEach(({ el, minHeight, overflow }) => { + el.style.minHeight = minHeight; + el.style.overflow = overflow; + }); + if (api) { + api.setGridOption('domLayout', 'normal'); + if (savedColumnState) { + api.applyColumnState?.({ + state: savedColumnState, + applyOrder: false, + }); + } + } + } + return; + } + + // All other chart types: use the clone-based approach let cleanup: (() => void) | null = null; try { @@ -289,12 +468,6 @@ export default function downloadAsImageOptimized( ); cleanup = cleanupFn; - const filter = (node: Element) => - typeof node.className === 'string' - ? !node.className.includes('mapboxgl-control-container') && - !node.className.includes('header-controls') - : true; - const dataUrl = await domToImage.toJpeg(clone, { bgcolor: theme?.colorBgContainer, filter,