mirror of
https://github.com/apache/superset.git
synced 2026-08-18 06:01:16 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b31f3b644 |
@@ -46,7 +46,11 @@ import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
|
||||
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
|
||||
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
|
||||
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
import { EMPTY_CONTAINER_Z_INDEX } from 'src/dashboard/constants';
|
||||
import {
|
||||
EMPTY_CONTAINER_Z_INDEX,
|
||||
FORCE_IN_VIEW_EVENT,
|
||||
RESTORE_VIRTUALIZATION_EVENT,
|
||||
} from 'src/dashboard/constants';
|
||||
import { isCurrentUserBot } from 'src/utils/isBot';
|
||||
|
||||
export type RowProps = {
|
||||
@@ -207,6 +211,38 @@ const Row = memo((props: RowProps) => {
|
||||
observerEnabler.observe(element);
|
||||
observerDisabler.observe(element);
|
||||
}
|
||||
|
||||
// Client-side "Download as Image/PDF" (see src/utils/downloadUtils.ts)
|
||||
// dispatches these events so off-screen rows render before the capture
|
||||
// and lazy loading is restored afterwards. Without this, virtualized
|
||||
// charts are exported as loading spinners.
|
||||
const handleForceInView = () => {
|
||||
observerEnabler?.disconnect();
|
||||
observerDisabler?.disconnect();
|
||||
setIsInView(true);
|
||||
};
|
||||
const handleRestoreVirtualization = () => {
|
||||
const el = containerRef.current;
|
||||
if (el) {
|
||||
observerEnabler?.observe(el);
|
||||
observerDisabler?.observe(el);
|
||||
}
|
||||
};
|
||||
window.addEventListener(FORCE_IN_VIEW_EVENT, handleForceInView);
|
||||
window.addEventListener(
|
||||
RESTORE_VIRTUALIZATION_EVENT,
|
||||
handleRestoreVirtualization,
|
||||
);
|
||||
|
||||
return () => {
|
||||
observerEnabler?.disconnect();
|
||||
observerDisabler?.disconnect();
|
||||
window.removeEventListener(FORCE_IN_VIEW_EVENT, handleForceInView);
|
||||
window.removeEventListener(
|
||||
RESTORE_VIRTUALIZATION_EVENT,
|
||||
handleRestoreVirtualization,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -51,3 +51,16 @@ export const DEFAULT_CROSS_FILTER_SCOPING: NativeFilterScope = {
|
||||
|
||||
export const CHART_WIDTH = 4;
|
||||
export const CHART_HEIGHT = 50;
|
||||
|
||||
/**
|
||||
* Window events used to coordinate dashboard virtualization when capturing the
|
||||
* dashboard for a client-side export (Download as Image/PDF).
|
||||
*
|
||||
* When DASHBOARD_VIRTUALIZATION is enabled, charts more than a viewport away
|
||||
* are unmounted, so a naive DOM capture records loading spinners instead of the
|
||||
* charts. The export utilities dispatch FORCE_IN_VIEW_EVENT to make every Row
|
||||
* render its content, wait for the charts to finish loading, capture, then
|
||||
* dispatch RESTORE_VIRTUALIZATION_EVENT to re-enable lazy loading.
|
||||
*/
|
||||
export const FORCE_IN_VIEW_EVENT = 'superset-force-all-in-view';
|
||||
export const RESTORE_VIRTUALIZATION_EVENT = 'superset-restore-virtualization';
|
||||
|
||||
@@ -23,6 +23,7 @@ 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';
|
||||
import { forceLoadAllCharts, restoreVirtualization } from './downloadUtils';
|
||||
|
||||
const IMAGE_DOWNLOAD_QUALITY = 0.95;
|
||||
const PNG_SCALE = 2; // Higher quality for PNG
|
||||
@@ -337,6 +338,11 @@ export default function downloadAsImageOptimized(
|
||||
return;
|
||||
}
|
||||
|
||||
// Force any virtualized (unmounted) charts to render before capturing, so
|
||||
// off-screen rows are not exported as loading spinners. Must be restored on
|
||||
// every exit path below.
|
||||
const didForceLoad = await forceLoadAllCharts(elementToPrint);
|
||||
|
||||
const filter = (node: Element) =>
|
||||
typeof node.className === 'string'
|
||||
? !node.className.includes('mapboxgl-control-container') &&
|
||||
@@ -374,6 +380,11 @@ export default function downloadAsImageOptimized(
|
||||
addWarningToast(
|
||||
t('The chart is still loading. Please wait a moment and try again.'),
|
||||
);
|
||||
// This early return skips the capture, so restore virtualization here;
|
||||
// otherwise it would stay forced-on for the rest of the session.
|
||||
if (didForceLoad) {
|
||||
restoreVirtualization();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -483,6 +494,9 @@ export default function downloadAsImageOptimized(
|
||||
});
|
||||
}
|
||||
}
|
||||
if (didForceLoad) {
|
||||
restoreVirtualization();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -533,6 +547,9 @@ export default function downloadAsImageOptimized(
|
||||
);
|
||||
} finally {
|
||||
if (cleanup) cleanup();
|
||||
if (didForceLoad) {
|
||||
restoreVirtualization();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { t } from '@apache-superset/core/translation';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { addWarningToast } from 'src/components/MessageToasts/actions';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { forceLoadAllCharts, restoreVirtualization } from './downloadUtils';
|
||||
|
||||
const pdfCompressionLevel = getBootstrapData().common.pdf_compression_level;
|
||||
|
||||
@@ -49,7 +50,7 @@ export default function downloadAsPdf(
|
||||
description: string,
|
||||
isExactSelector = false,
|
||||
) {
|
||||
return (event: SyntheticEvent) => {
|
||||
return async (event: SyntheticEvent) => {
|
||||
const elementToPrint = isExactSelector
|
||||
? document.querySelector(selector)
|
||||
: event.currentTarget.closest(selector);
|
||||
@@ -60,6 +61,10 @@ export default function downloadAsPdf(
|
||||
);
|
||||
}
|
||||
|
||||
// Force any virtualized (unmounted) charts to render before capturing, so
|
||||
// off-screen rows are not exported as loading spinners.
|
||||
const didForceLoad = await forceLoadAllCharts(elementToPrint);
|
||||
|
||||
const options = {
|
||||
margin: 10,
|
||||
compression: pdfCompressionLevel,
|
||||
@@ -74,6 +79,11 @@ export default function downloadAsPdf(
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
logging.error('PDF generation failed', e);
|
||||
})
|
||||
.finally(() => {
|
||||
if (didForceLoad) {
|
||||
restoreVirtualization();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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 { isFeatureEnabled } from '@superset-ui/core';
|
||||
import { addWarningToast } from 'src/components/MessageToasts/actions';
|
||||
import {
|
||||
FORCE_IN_VIEW_EVENT,
|
||||
RESTORE_VIRTUALIZATION_EVENT,
|
||||
} from 'src/dashboard/constants';
|
||||
import { forceLoadAllCharts, restoreVirtualization } from './downloadUtils';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
isFeatureEnabled: jest.fn(),
|
||||
FeatureFlag: {
|
||||
DashboardVirtualization: 'DASHBOARD_VIRTUALIZATION',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/constants', () => ({
|
||||
FORCE_IN_VIEW_EVENT: 'superset-force-all-in-view',
|
||||
RESTORE_VIRTUALIZATION_EVENT: 'superset-restore-virtualization',
|
||||
}));
|
||||
|
||||
jest.mock('@apache-superset/core/translation', () => ({
|
||||
t: (str: string) => str,
|
||||
}));
|
||||
|
||||
jest.mock('@apache-superset/core/utils', () => ({
|
||||
logging: { warn: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('src/components/MessageToasts/actions', () => ({
|
||||
addWarningToast: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('forceLoadAllCharts returns false and dispatches nothing when virtualization is disabled', async () => {
|
||||
mockIsFeatureEnabled.mockReturnValue(false);
|
||||
const dispatchSpy = jest.spyOn(window, 'dispatchEvent');
|
||||
const container = document.createElement('div');
|
||||
|
||||
const result = await forceLoadAllCharts(container);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('forceLoadAllCharts dispatches the force-in-view event and resolves true once charts finish loading', async () => {
|
||||
jest.useFakeTimers();
|
||||
mockIsFeatureEnabled.mockReturnValue(true);
|
||||
const dispatchSpy = jest.spyOn(window, 'dispatchEvent');
|
||||
// No `.loading` elements => charts are considered loaded.
|
||||
const container = document.createElement('div');
|
||||
|
||||
const promise = forceLoadAllCharts(container);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: FORCE_IN_VIEW_EVENT }),
|
||||
);
|
||||
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(addWarningToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('forceLoadAllCharts warns when charts never finish loading before the timeout', async () => {
|
||||
jest.useFakeTimers();
|
||||
mockIsFeatureEnabled.mockReturnValue(true);
|
||||
const container = document.createElement('div');
|
||||
const loadingChart = document.createElement('div');
|
||||
loadingChart.className = 'loading';
|
||||
container.appendChild(loadingChart);
|
||||
|
||||
const promise = forceLoadAllCharts(container);
|
||||
|
||||
// Advance past the 60s timeout while a `.loading` element is still present.
|
||||
await jest.advanceTimersByTimeAsync(61_000);
|
||||
const result = await promise;
|
||||
|
||||
// Virtualization was active, so the caller must still restore it.
|
||||
expect(result).toBe(true);
|
||||
expect(addWarningToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('restoreVirtualization dispatches the restore event', () => {
|
||||
const dispatchSpy = jest.spyOn(window, 'dispatchEvent');
|
||||
|
||||
restoreVirtualization();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: RESTORE_VIRTUALIZATION_EVENT }),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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 { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { addWarningToast } from 'src/components/MessageToasts/actions';
|
||||
import {
|
||||
FORCE_IN_VIEW_EVENT,
|
||||
RESTORE_VIRTUALIZATION_EVENT,
|
||||
} from 'src/dashboard/constants';
|
||||
|
||||
/**
|
||||
* Poll until all `.loading` spinners inside a container disappear,
|
||||
* indicating that lazy-loaded charts have finished rendering.
|
||||
* Returns true if all charts loaded, false if timed out.
|
||||
*/
|
||||
function waitForChartsToLoad(
|
||||
container: Element,
|
||||
timeoutMs = 60_000,
|
||||
): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const startTime = Date.now();
|
||||
const check = () => {
|
||||
const loadingElements = container.querySelectorAll('.loading');
|
||||
if (loadingElements.length === 0) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
logging.warn(
|
||||
`Timed out waiting for ${loadingElements.length} chart(s) to load`,
|
||||
);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
setTimeout(check, 500);
|
||||
};
|
||||
setTimeout(check, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When DASHBOARD_VIRTUALIZATION is enabled, forces all lazy-loaded
|
||||
* charts to render and waits for them to finish loading.
|
||||
* Returns true if virtualization was active (caller must restore it).
|
||||
*/
|
||||
export async function forceLoadAllCharts(container: Element): Promise<boolean> {
|
||||
const useVirtualization = isFeatureEnabled(
|
||||
FeatureFlag.DashboardVirtualization,
|
||||
);
|
||||
if (useVirtualization) {
|
||||
window.dispatchEvent(new Event(FORCE_IN_VIEW_EVENT));
|
||||
const allLoaded = await waitForChartsToLoad(container);
|
||||
if (!allLoaded) {
|
||||
addWarningToast(
|
||||
t('Some charts did not finish loading. The export may be incomplete.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
return useVirtualization;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores normal lazy loading behavior after a forced load.
|
||||
*/
|
||||
export function restoreVirtualization(): void {
|
||||
window.dispatchEvent(new Event(RESTORE_VIRTUALIZATION_EVENT));
|
||||
}
|
||||
Reference in New Issue
Block a user