mirror of
https://github.com/apache/superset.git
synced 2026-09-01 13:01:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85696292a7 | ||
|
|
0c0235388b | ||
|
|
9b31f3b644 |
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
|
act,
|
||||||
fireEvent,
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
RenderResult,
|
RenderResult,
|
||||||
@@ -25,6 +26,10 @@ import {
|
|||||||
} from 'spec/helpers/testing-library';
|
} from 'spec/helpers/testing-library';
|
||||||
|
|
||||||
import { DASHBOARD_GRID_ID } from 'src/dashboard/util/constants';
|
import { DASHBOARD_GRID_ID } from 'src/dashboard/util/constants';
|
||||||
|
import {
|
||||||
|
FORCE_IN_VIEW_EVENT,
|
||||||
|
RESTORE_VIRTUALIZATION_EVENT,
|
||||||
|
} from 'src/dashboard/constants';
|
||||||
import { getMockStore } from 'spec/fixtures/mockStore';
|
import { getMockStore } from 'spec/fixtures/mockStore';
|
||||||
import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
|
import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
|
||||||
import { initialState } from 'src/SqlLab/fixtures';
|
import { initialState } from 'src/SqlLab/fixtures';
|
||||||
@@ -328,4 +333,58 @@ describe('visibility handling for intersection observers', () => {
|
|||||||
expect(() => callback([nonIntersectingEntry])).not.toThrow();
|
expect(() => callback([nonIntersectingEntry])).not.toThrow();
|
||||||
expect(callback([nonIntersectingEntry])).toBe(false);
|
expect(callback([nonIntersectingEntry])).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('force-in-view event with no detail disconnects the observers for every row', () => {
|
||||||
|
setup({ isComponentVisible: true });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event(FORCE_IN_VIEW_EVENT));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDisconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('force-in-view event scoped to other rowIds does not disconnect this row', () => {
|
||||||
|
setup({ isComponentVisible: true });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(FORCE_IN_VIEW_EVENT, {
|
||||||
|
detail: { rowIds: ['SOME_OTHER_ROW_ID'] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDisconnect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('force-in-view event scoped to this rowId disconnects the observers', () => {
|
||||||
|
setup({ isComponentVisible: true });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(FORCE_IN_VIEW_EVENT, {
|
||||||
|
detail: { rowIds: [props.id] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockDisconnect).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore-virtualization event re-observes after a force-in-view', () => {
|
||||||
|
setup({ isComponentVisible: true });
|
||||||
|
expect(mockObserve).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event(FORCE_IN_VIEW_EVENT));
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event(RESTORE_VIRTUALIZATION_EVENT));
|
||||||
|
});
|
||||||
|
|
||||||
|
// The initial mount observes twice (enabler + disabler); restoring
|
||||||
|
// after a force-in-view re-observes both again.
|
||||||
|
expect(mockObserve).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
|
|||||||
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
|
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
|
||||||
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
|
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
|
||||||
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
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';
|
import { isCurrentUserBot } from 'src/utils/isBot';
|
||||||
|
|
||||||
export type RowProps = {
|
export type RowProps = {
|
||||||
@@ -207,6 +211,49 @@ const Row = memo((props: RowProps) => {
|
|||||||
observerEnabler.observe(element);
|
observerEnabler.observe(element);
|
||||||
observerDisabler.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.
|
||||||
|
//
|
||||||
|
// The force event optionally carries a `rowIds` batch in its detail so
|
||||||
|
// large dashboards can be force-rendered a few rows at a time instead
|
||||||
|
// of every row at once (see FORCE_RENDER_BATCH_SIZE in
|
||||||
|
// downloadUtils.ts). No detail means "force every row", preserving the
|
||||||
|
// original single-shot behavior for any other future caller.
|
||||||
|
const handleForceInView = (event: Event) => {
|
||||||
|
const rowIds = (event as CustomEvent<{ rowIds?: string[] }>).detail
|
||||||
|
?.rowIds;
|
||||||
|
if (rowIds && !rowIds.includes(rowComponent.id as string)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 () => {
|
return () => {
|
||||||
@@ -305,6 +352,7 @@ const Row = memo((props: RowProps) => {
|
|||||||
backgroundStyle.className,
|
backgroundStyle.className,
|
||||||
)}
|
)}
|
||||||
data-test={`grid-row-${backgroundStyle.className}`}
|
data-test={`grid-row-${backgroundStyle.className}`}
|
||||||
|
data-row-id={rowComponent.id}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -51,3 +51,16 @@ export const DEFAULT_CROSS_FILTER_SCOPING: NativeFilterScope = {
|
|||||||
|
|
||||||
export const CHART_WIDTH = 4;
|
export const CHART_WIDTH = 4;
|
||||||
export const CHART_HEIGHT = 50;
|
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 { SupersetTheme } from '@apache-superset/core/theme';
|
||||||
import { addWarningToast } from 'src/components/MessageToasts/actions';
|
import { addWarningToast } from 'src/components/MessageToasts/actions';
|
||||||
import type { AgGridContainerElement } from '@superset-ui/core/components';
|
import type { AgGridContainerElement } from '@superset-ui/core/components';
|
||||||
|
import { forceLoadAllCharts, restoreVirtualization } from './downloadUtils';
|
||||||
|
|
||||||
const IMAGE_DOWNLOAD_QUALITY = 0.95;
|
const IMAGE_DOWNLOAD_QUALITY = 0.95;
|
||||||
const PNG_SCALE = 2; // Higher quality for PNG
|
const PNG_SCALE = 2; // Higher quality for PNG
|
||||||
@@ -337,6 +338,11 @@ export default function downloadAsImageOptimized(
|
|||||||
return;
|
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) =>
|
const filter = (node: Element) =>
|
||||||
typeof node.className === 'string'
|
typeof node.className === 'string'
|
||||||
? !node.className.includes('mapboxgl-control-container') &&
|
? !node.className.includes('mapboxgl-control-container') &&
|
||||||
@@ -374,6 +380,11 @@ export default function downloadAsImageOptimized(
|
|||||||
addWarningToast(
|
addWarningToast(
|
||||||
t('The chart is still loading. Please wait a moment and try again.'),
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,6 +494,9 @@ export default function downloadAsImageOptimized(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (didForceLoad) {
|
||||||
|
restoreVirtualization();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -533,6 +547,9 @@ export default function downloadAsImageOptimized(
|
|||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (cleanup) cleanup();
|
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 { logging } from '@apache-superset/core/utils';
|
||||||
import { addWarningToast } from 'src/components/MessageToasts/actions';
|
import { addWarningToast } from 'src/components/MessageToasts/actions';
|
||||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||||
|
import { forceLoadAllCharts, restoreVirtualization } from './downloadUtils';
|
||||||
|
|
||||||
const pdfCompressionLevel = getBootstrapData().common.pdf_compression_level;
|
const pdfCompressionLevel = getBootstrapData().common.pdf_compression_level;
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export default function downloadAsPdf(
|
|||||||
description: string,
|
description: string,
|
||||||
isExactSelector = false,
|
isExactSelector = false,
|
||||||
) {
|
) {
|
||||||
return (event: SyntheticEvent) => {
|
return async (event: SyntheticEvent) => {
|
||||||
const elementToPrint = isExactSelector
|
const elementToPrint = isExactSelector
|
||||||
? document.querySelector(selector)
|
? document.querySelector(selector)
|
||||||
: event.currentTarget.closest(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 = {
|
const options = {
|
||||||
margin: 10,
|
margin: 10,
|
||||||
compression: pdfCompressionLevel,
|
compression: pdfCompressionLevel,
|
||||||
@@ -74,6 +79,11 @@ export default function downloadAsPdf(
|
|||||||
})
|
})
|
||||||
.catch((e: Error) => {
|
.catch((e: Error) => {
|
||||||
logging.error('PDF generation failed', e);
|
logging.error('PDF generation failed', e);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (didForceLoad) {
|
||||||
|
restoreVirtualization();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
addInfoToast,
|
||||||
|
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, values?: Record<string, unknown>) =>
|
||||||
|
values ? `${str} ${JSON.stringify(values)}` : str,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@apache-superset/core/utils', () => ({
|
||||||
|
logging: { warn: jest.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('src/components/MessageToasts/actions', () => ({
|
||||||
|
addInfoToast: jest.fn(),
|
||||||
|
addWarningToast: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||||
|
|
||||||
|
function makeRow(id: string, loading = false): HTMLDivElement {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.setAttribute('data-row-id', id);
|
||||||
|
if (loading) {
|
||||||
|
const spinner = document.createElement('div');
|
||||||
|
spinner.className = 'loading';
|
||||||
|
row.appendChild(spinner);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
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('forceLoadAllCharts dispatches a single force-in-view event when rows fit in one batch', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
mockIsFeatureEnabled.mockReturnValue(true);
|
||||||
|
const dispatchSpy = jest.spyOn(window, 'dispatchEvent');
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.append(makeRow('a'), makeRow('b'), makeRow('c'));
|
||||||
|
|
||||||
|
const promise = forceLoadAllCharts(container);
|
||||||
|
await jest.advanceTimersByTimeAsync(2000);
|
||||||
|
const result = await promise;
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(addInfoToast).not.toHaveBeenCalled();
|
||||||
|
const forceEvents = dispatchSpy.mock.calls
|
||||||
|
.map(([event]) => event as Event)
|
||||||
|
.filter(event => event.type === FORCE_IN_VIEW_EVENT);
|
||||||
|
expect(forceEvents).toHaveLength(1);
|
||||||
|
expect((forceEvents[0] as CustomEvent).detail).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forceLoadAllCharts batches rows in groups rather than forcing everything at once', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
mockIsFeatureEnabled.mockReturnValue(true);
|
||||||
|
const dispatchSpy = jest.spyOn(window, 'dispatchEvent');
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const rowIds = Array.from({ length: 12 }, (_, i) => `row-${i}`);
|
||||||
|
rowIds.forEach(id => container.appendChild(makeRow(id)));
|
||||||
|
|
||||||
|
const onProgress = jest.fn();
|
||||||
|
const promise = forceLoadAllCharts(container, onProgress);
|
||||||
|
|
||||||
|
// 3 sequential batch waits + the final whole-container check, ~1s each
|
||||||
|
// since nothing is ever `.loading`.
|
||||||
|
await jest.advanceTimersByTimeAsync(5000);
|
||||||
|
const result = await promise;
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(addInfoToast).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const forceEvents = dispatchSpy.mock.calls
|
||||||
|
.map(([event]) => event as CustomEvent<{ rowIds: string[] }>)
|
||||||
|
.filter(event => event.type === FORCE_IN_VIEW_EVENT);
|
||||||
|
|
||||||
|
expect(forceEvents).toHaveLength(3);
|
||||||
|
expect(forceEvents[0].detail.rowIds).toEqual(rowIds.slice(0, 5));
|
||||||
|
expect(forceEvents[1].detail.rowIds).toEqual(rowIds.slice(5, 10));
|
||||||
|
expect(forceEvents[2].detail.rowIds).toEqual(rowIds.slice(10, 12));
|
||||||
|
|
||||||
|
expect(onProgress).toHaveBeenNthCalledWith(1, {
|
||||||
|
loadedBatches: 1,
|
||||||
|
totalBatches: 3,
|
||||||
|
});
|
||||||
|
expect(onProgress).toHaveBeenNthCalledWith(2, {
|
||||||
|
loadedBatches: 2,
|
||||||
|
totalBatches: 3,
|
||||||
|
});
|
||||||
|
expect(onProgress).toHaveBeenNthCalledWith(3, {
|
||||||
|
loadedBatches: 3,
|
||||||
|
totalBatches: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forceLoadAllCharts moves on to the next batch even if the current one times out', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
mockIsFeatureEnabled.mockReturnValue(true);
|
||||||
|
const container = document.createElement('div');
|
||||||
|
// Batch 1 (5 rows): one spinner is slow to clear.
|
||||||
|
const stuck = makeRow('stuck', true);
|
||||||
|
container.appendChild(stuck);
|
||||||
|
for (let i = 1; i < 5; i += 1) {
|
||||||
|
container.appendChild(makeRow(`row-${i}`));
|
||||||
|
}
|
||||||
|
// Batch 2 (1 row): loads fine.
|
||||||
|
container.appendChild(makeRow('row-5'));
|
||||||
|
|
||||||
|
const onProgress = jest.fn();
|
||||||
|
const promise = forceLoadAllCharts(container, onProgress);
|
||||||
|
|
||||||
|
// Batch 1 gives up waiting at the 10s per-batch cap since `stuck` hasn't
|
||||||
|
// cleared yet, but moves on to batch 2 (which has nothing loading and
|
||||||
|
// resolves on its own first poll) instead of blocking the whole export
|
||||||
|
// on one slow chart.
|
||||||
|
await jest.advanceTimersByTimeAsync(12_000);
|
||||||
|
expect(onProgress).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// The chart finishes just after its batch gave up on it. The final
|
||||||
|
// whole-container check (a safety net for exactly this) should pick that
|
||||||
|
// up on its next poll rather than needing its own full 60s timeout.
|
||||||
|
stuck.querySelector('.loading')?.remove();
|
||||||
|
await jest.advanceTimersByTimeAsync(1000);
|
||||||
|
const result = await promise;
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(addWarningToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forceLoadAllCharts caps the total wait across batches to the overall deadline', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
mockIsFeatureEnabled.mockReturnValue(true);
|
||||||
|
const container = document.createElement('div');
|
||||||
|
// 8 batches of 5 rows, every row permanently `.loading`: with no overall
|
||||||
|
// deadline this would burn a full 10s per batch (80s) plus another 60s on
|
||||||
|
// the final whole-container check (140s total) before giving up.
|
||||||
|
const rowIds = Array.from({ length: 40 }, (_, i) => `row-${i}`);
|
||||||
|
rowIds.forEach(id => container.appendChild(makeRow(id, true)));
|
||||||
|
|
||||||
|
const promise = forceLoadAllCharts(container);
|
||||||
|
|
||||||
|
// Comfortably past the 60s overall budget, but far short of the 140s the
|
||||||
|
// unbounded, per-timeout-summed behavior would have required.
|
||||||
|
await jest.advanceTimersByTimeAsync(65_000);
|
||||||
|
const result = await promise;
|
||||||
|
|
||||||
|
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,197 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
addInfoToast,
|
||||||
|
addWarningToast,
|
||||||
|
} from 'src/components/MessageToasts/actions';
|
||||||
|
import {
|
||||||
|
FORCE_IN_VIEW_EVENT,
|
||||||
|
RESTORE_VIRTUALIZATION_EVENT,
|
||||||
|
} from 'src/dashboard/constants';
|
||||||
|
|
||||||
|
// Rows carry a `data-row-id` attribute (see Row.tsx) so the export path can
|
||||||
|
// target a subset of them per batch. How many rows get forced into view at
|
||||||
|
// once: large enough that a small dashboard finishes in one batch, small
|
||||||
|
// enough that a dashboard with hundreds of rows doesn't fire hundreds of
|
||||||
|
// chart queries in the same tick, which is the exact thundering-herd load
|
||||||
|
// DASHBOARD_VIRTUALIZATION exists to prevent in the first place.
|
||||||
|
const FORCE_RENDER_BATCH_SIZE = 5;
|
||||||
|
// Upper bound on how long a single batch is given to finish loading before
|
||||||
|
// moving on to the next one. Intentionally shorter than the final,
|
||||||
|
// whole-container timeout below: a slow chart in one batch shouldn't stall
|
||||||
|
// every later batch, since the final check catches stragglers anyway.
|
||||||
|
const BATCH_LOAD_TIMEOUT_MS = 10_000;
|
||||||
|
// Overall budget for the whole force-load pass (every batch wait plus the
|
||||||
|
// final whole-container check combined). Without this, a dashboard with
|
||||||
|
// many permanently-stalled batches could burn its full per-batch timeout
|
||||||
|
// on each one, plus another full timeout on the final check, keeping the
|
||||||
|
// export blocked far longer than any single timeout value suggests.
|
||||||
|
const OVERALL_LOAD_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
export type ForceLoadProgress = {
|
||||||
|
loadedBatches: number;
|
||||||
|
totalBatches: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll until none of the given row elements contain a `.loading` spinner.
|
||||||
|
* Scoped to just those rows (rather than the whole container, like
|
||||||
|
* waitForChartsToLoad above) so a chart stuck in an earlier batch doesn't
|
||||||
|
* force every later batch to also burn its full timeout re-checking that
|
||||||
|
* same stale spinner. Resolves (doesn't reject) either way; a straggler
|
||||||
|
* here is still caught by the final whole-container check afterwards.
|
||||||
|
*/
|
||||||
|
function waitForRowsToLoad(rows: Element[], timeoutMs: number): Promise<void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const check = () => {
|
||||||
|
const stillLoading = rows.some(row => row.querySelector('.loading'));
|
||||||
|
if (!stillLoading || Date.now() - startTime > timeoutMs) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(check, 500);
|
||||||
|
};
|
||||||
|
setTimeout(check, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowElements(container: Element): Element[] {
|
||||||
|
return Array.from(container.querySelectorAll('[data-row-id]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowId(row: Element): string | null {
|
||||||
|
return row.getAttribute('data-row-id');
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk<T>(items: T[], size: number): T[][] {
|
||||||
|
const batches: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += size) {
|
||||||
|
batches.push(items.slice(i, i + size));
|
||||||
|
}
|
||||||
|
return batches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When DASHBOARD_VIRTUALIZATION is enabled, forces lazy-loaded charts to
|
||||||
|
* render in small batches (rather than all at once) and waits for them to
|
||||||
|
* finish loading. Returns true if virtualization was active (caller must
|
||||||
|
* restore it).
|
||||||
|
*/
|
||||||
|
export async function forceLoadAllCharts(
|
||||||
|
container: Element,
|
||||||
|
onProgress?: (progress: ForceLoadProgress) => void,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const useVirtualization = isFeatureEnabled(
|
||||||
|
FeatureFlag.DashboardVirtualization,
|
||||||
|
);
|
||||||
|
if (useVirtualization) {
|
||||||
|
const deadline = Date.now() + OVERALL_LOAD_TIMEOUT_MS;
|
||||||
|
const rowElements = getRowElements(container);
|
||||||
|
const rowBatches = rowElements.length
|
||||||
|
? chunk(rowElements, FORCE_RENDER_BATCH_SIZE)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (rowBatches.length <= 1) {
|
||||||
|
// Nothing to batch (no rows found, or everything fits in one batch):
|
||||||
|
// force everything into view in a single pass, same as before batching.
|
||||||
|
window.dispatchEvent(new Event(FORCE_IN_VIEW_EVENT));
|
||||||
|
} else {
|
||||||
|
addInfoToast(
|
||||||
|
t('Preparing %(count)s charts for export. This may take a moment.', {
|
||||||
|
count: rowElements.length,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line no-restricted-syntax -- batches must be
|
||||||
|
// dispatched sequentially so the query burst is actually staggered.
|
||||||
|
for (const [index, batch] of rowBatches.entries()) {
|
||||||
|
const rowIds = batch
|
||||||
|
.map(getRowId)
|
||||||
|
.filter((id): id is string => id !== null);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(FORCE_IN_VIEW_EVENT, { detail: { rowIds } }),
|
||||||
|
);
|
||||||
|
// Never wait longer than what's left of the overall budget, so a
|
||||||
|
// string of stalled batches can't each burn a full per-batch
|
||||||
|
// timeout and blow past the deadline in aggregate.
|
||||||
|
const remainingMs = Math.max(0, deadline - Date.now());
|
||||||
|
// eslint-disable-next-line no-await-in-loop -- see above
|
||||||
|
await waitForRowsToLoad(
|
||||||
|
batch,
|
||||||
|
Math.min(BATCH_LOAD_TIMEOUT_MS, remainingMs),
|
||||||
|
);
|
||||||
|
onProgress?.({
|
||||||
|
loadedBatches: index + 1,
|
||||||
|
totalBatches: rowBatches.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allLoaded = await waitForChartsToLoad(
|
||||||
|
container,
|
||||||
|
Math.max(0, deadline - Date.now()),
|
||||||
|
);
|
||||||
|
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