You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/09/11 00:37:49 UTC
(superset) 01/01: fix(dashboard,explore): wire addWarningToast into downloadAsImage/downloadAsPdf callers
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch fix/download-warning-toasts-wire-dispatch
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 93f600614d195987b9ce83e7ed107bdf97e0acd8
Author: Evan Rusackas <ev...@rusackas.com>
AuthorDate: Thu Sep 10 17:36:30 2026 -0700
fix(dashboard,explore): wire addWarningToast into downloadAsImage/downloadAsPdf callers
downloadAsImage and downloadAsPdf both accepted addWarningToast as a raw,
unbound action creator imported directly from actions.ts. Called without
dispatch, that only builds a plain Redux action object and never renders a
toast, so a failed image/PDF export failed silently. Mirrors the fix PR
#44031 made for downloadAsPivotExcel: both now take an optional trailing
addWarningToast callback, and every call site threads through an
already-bound callback from useToasts()/bindActionCreators instead.
Co-Authored-By: Claude Sonnet 5 <no...@anthropic.com>
---
.../src/dashboard/components/SliceHeader/index.tsx | 2 +
.../components/SliceHeaderControls/index.tsx | 5 +
.../components/SliceHeaderControls/types.ts | 1 +
.../components/gridComponents/Chart/Chart.tsx | 1 +
.../components/menu/DownloadMenuItems/index.tsx | 18 +-
.../useExploreAdditionalActionsMenu/index.tsx | 39 +++-
.../useExploreAdditionalActionsMenu.test.tsx | 16 +-
.../src/utils/downloadAsImage.test.ts | 227 +++++++++++++++++----
superset-frontend/src/utils/downloadAsImage.tsx | 13 +-
superset-frontend/src/utils/downloadAsPdf.test.ts | 66 ++++++
superset-frontend/src/utils/downloadAsPdf.ts | 8 +-
11 files changed, 338 insertions(+), 58 deletions(-)
diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
index f674d91ee79..8fc85064424 100644
--- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
@@ -180,6 +180,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
dashboardId,
addSuccessToast,
addDangerToast,
+ addWarningToast,
handleToggleFullSize,
isFullSize,
chartStatus,
@@ -416,6 +417,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
dashboardId={dashboardId}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
+ addWarningToast={addWarningToast}
handleToggleFullSize={handleToggleFullSize}
isFullSize={isFullSize}
isDescriptionExpanded={isExpanded}
diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
index 2620d8f8807..aeca8a3718b 100644
--- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
@@ -142,6 +142,7 @@ export interface SliceHeaderControlsProps {
addDangerToast: (message: string) => void;
addSuccessToast: (message: string) => void;
+ addWarningToast?: (message: string) => void;
supersetCanExplore?: boolean;
supersetCanShare?: boolean;
@@ -333,6 +334,8 @@ const SliceHeaderControls = (
props.slice.slice_name,
true,
theme,
+ undefined,
+ props.addWarningToast,
)(domEvent),
).finally(() => {
if (menu) {
@@ -364,6 +367,7 @@ const SliceHeaderControls = (
true,
theme,
{ format: 'png', backgroundType },
+ props.addWarningToast,
)(domEvent),
).finally(() => {
if (menu) {
@@ -391,6 +395,7 @@ const SliceHeaderControls = (
getScreenshotNodeSelector(props.slice.slice_id),
props.slice.slice_name,
true,
+ props.addWarningToast,
)(domEvent),
).finally(() => {
if (menu) {
diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts b/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts
index 58d7f9352e2..924902fb51f 100644
--- a/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts
+++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts
@@ -54,6 +54,7 @@ export interface SliceHeaderControlsProps {
addDangerToast: (message: string) => void;
addSuccessToast: (message: string) => void;
+ addWarningToast?: (message: string) => void;
supersetCanExplore?: boolean;
supersetCanShare?: boolean;
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx
index 2b0456a731b..64a6cf0a760 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx
@@ -770,6 +770,7 @@ const Chart = (props: ChartProps) => {
filters={getActiveFilters() || EMPTY_OBJECT}
addSuccessToast={boundActionCreators.addSuccessToast}
addDangerToast={boundActionCreators.addDangerToast}
+ addWarningToast={boundActionCreators.addWarningToast}
handleToggleFullSize={props.handleToggleFullSize}
isFullSize={props.isFullSize}
chartStatus={chartStatus || ''}
diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
index 248d2795260..d61b2c989e8 100644
--- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
+++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
@@ -70,7 +70,7 @@ export const useDownloadMenuItems = (
canExportImage,
} = props;
- const { addDangerToast, addSuccessToast } = useToasts();
+ const { addDangerToast, addSuccessToast, addWarningToast } = useToasts();
const dataMask = useSelector((state: RootState) => state.dataMask);
const SCREENSHOT_NODE_SELECTOR = '.dashboard';
@@ -92,7 +92,12 @@ export const useDownloadMenuItems = (
const onDownloadPdf = async (e: SyntheticEvent) => {
try {
- downloadAsPdf(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e);
+ downloadAsPdf(
+ SCREENSHOT_NODE_SELECTOR,
+ dashboardTitle,
+ true,
+ addWarningToast,
+ )(e);
} catch (error) {
logging.error(error);
addDangerToast(t('Sorry, something went wrong. Try again later.'));
@@ -102,7 +107,14 @@ export const useDownloadMenuItems = (
const onDownloadImage = async (e: SyntheticEvent) => {
try {
- downloadAsImage(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e);
+ downloadAsImage(
+ SCREENSHOT_NODE_SELECTOR,
+ dashboardTitle,
+ true,
+ undefined,
+ undefined,
+ addWarningToast,
+ )(e);
} catch (error) {
logging.error(error);
addDangerToast(t('Sorry, something went wrong. Try again later.'));
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
index 9151ebabc5c..476821f0a9a 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
@@ -161,6 +161,7 @@ export function getExportScreenshotMenuItems({
transparentKey,
solidKey,
pdfKey,
+ addWarningToast,
}: {
chartSelector: string;
sliceName: string;
@@ -172,6 +173,7 @@ export function getExportScreenshotMenuItems({
transparentKey: string;
solidKey: string;
pdfKey: string;
+ addWarningToast?: (message: string) => void;
}) {
return [
{
@@ -186,10 +188,14 @@ export function getExportScreenshotMenuItems({
onClick: (e: {
domEvent: React.MouseEvent | React.KeyboardEvent;
}) => {
- downloadAsImage(chartSelector, sliceName, true, theme, {
- format: 'png',
- backgroundType: 'transparent',
- })(e.domEvent);
+ downloadAsImage(
+ chartSelector,
+ sliceName,
+ true,
+ theme,
+ { format: 'png', backgroundType: 'transparent' },
+ addWarningToast,
+ )(e.domEvent);
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_PNG, {
@@ -206,10 +212,14 @@ export function getExportScreenshotMenuItems({
onClick: (e: {
domEvent: React.MouseEvent | React.KeyboardEvent;
}) => {
- downloadAsImage(chartSelector, sliceName, true, theme, {
- format: 'png',
- backgroundType: 'solid',
- })(e.domEvent);
+ downloadAsImage(
+ chartSelector,
+ sliceName,
+ true,
+ theme,
+ { format: 'png', backgroundType: 'solid' },
+ addWarningToast,
+ )(e.domEvent);
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_PNG, {
@@ -227,7 +237,12 @@ export function getExportScreenshotMenuItems({
label: t('Export as PDF'),
icon: <Icons.FileOutlined />,
onClick: (e: { domEvent: React.MouseEvent | React.KeyboardEvent }) => {
- downloadAsPdf(chartSelector, sliceName, true)(e.domEvent);
+ downloadAsPdf(
+ chartSelector,
+ sliceName,
+ true,
+ addWarningToast,
+ )(e.domEvent);
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_PDF, {
@@ -820,6 +835,8 @@ export const useExploreAdditionalActionsMenu = (
slice?.slice_name ?? t('New chart'),
true,
theme,
+ undefined,
+ addWarningToast,
)(e.domEvent);
setIsDropdownVisible(false);
dispatch(
@@ -841,6 +858,7 @@ export const useExploreAdditionalActionsMenu = (
transparentKey: MENU_KEYS.EXPORT_ALL_PNG_TRANSPARENT,
solidKey: MENU_KEYS.EXPORT_ALL_PNG_SOLID,
pdfKey: MENU_KEYS.EXPORT_ALL_PDF,
+ addWarningToast,
}),
{
key: MENU_KEYS.EXPORT_TO_XLSX,
@@ -941,6 +959,8 @@ export const useExploreAdditionalActionsMenu = (
slice?.slice_name ?? t('New chart'),
true,
theme,
+ undefined,
+ addWarningToast,
)(e.domEvent);
setIsDropdownVisible(false);
dispatch(
@@ -962,6 +982,7 @@ export const useExploreAdditionalActionsMenu = (
transparentKey: MENU_KEYS.EXPORT_CURRENT_PNG_TRANSPARENT,
solidKey: MENU_KEYS.EXPORT_CURRENT_PNG_SOLID,
pdfKey: MENU_KEYS.EXPORT_CURRENT_PDF,
+ addWarningToast,
}),
{
key: MENU_KEYS.EXPORT_CURRENT_XLSX,
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx
index 615b5a844fd..b776bc8690c 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreAdditionalActionsMenu.test.tsx
@@ -319,6 +319,7 @@ const domEvent = {} as React.MouseEvent;
const buildScreenshotItems = () => {
const setIsDropdownVisible = jest.fn();
const dispatch = jest.fn();
+ const addWarningToast = jest.fn();
const items = getExportScreenshotMenuItems({
chartSelector: CHART_SELECTOR,
sliceName: SLICE_NAME,
@@ -330,8 +331,9 @@ const buildScreenshotItems = () => {
transparentKey: 'export_png_transparent',
solidKey: 'export_png_solid',
pdfKey: 'export_pdf',
+ addWarningToast,
}) as any[];
- return { items, setIsDropdownVisible, dispatch };
+ return { items, setIsDropdownVisible, dispatch, addWarningToast };
};
test('getExportScreenshotMenuItems builds the PNG submenu and PDF item with the provided keys', () => {
@@ -346,7 +348,8 @@ test('getExportScreenshotMenuItems builds the PNG submenu and PDF item with the
});
test('getExportScreenshotMenuItems transparent option downloads a transparent PNG and dispatches a log event', () => {
- const { items, setIsDropdownVisible, dispatch } = buildScreenshotItems();
+ const { items, setIsDropdownVisible, dispatch, addWarningToast } =
+ buildScreenshotItems();
items[0].children[0].onClick({ domEvent });
@@ -356,13 +359,15 @@ test('getExportScreenshotMenuItems transparent option downloads a transparent PN
true,
expect.anything(),
{ format: 'png', backgroundType: 'transparent' },
+ addWarningToast,
);
expect(setIsDropdownVisible).toHaveBeenCalledWith(false);
expect(dispatch).toHaveBeenCalledTimes(1);
});
test('getExportScreenshotMenuItems solid option downloads a solid PNG and dispatches a log event', () => {
- const { items, setIsDropdownVisible, dispatch } = buildScreenshotItems();
+ const { items, setIsDropdownVisible, dispatch, addWarningToast } =
+ buildScreenshotItems();
items[0].children[1].onClick({ domEvent });
@@ -372,13 +377,15 @@ test('getExportScreenshotMenuItems solid option downloads a solid PNG and dispat
true,
expect.anything(),
{ format: 'png', backgroundType: 'solid' },
+ addWarningToast,
);
expect(setIsDropdownVisible).toHaveBeenCalledWith(false);
expect(dispatch).toHaveBeenCalledTimes(1);
});
test('getExportScreenshotMenuItems PDF option calls downloadAsPdf and dispatches a log event', () => {
- const { items, setIsDropdownVisible, dispatch } = buildScreenshotItems();
+ const { items, setIsDropdownVisible, dispatch, addWarningToast } =
+ buildScreenshotItems();
items[1].onClick({ domEvent });
@@ -386,6 +393,7 @@ test('getExportScreenshotMenuItems PDF option calls downloadAsPdf and dispatches
CHART_SELECTOR,
SLICE_NAME,
true,
+ addWarningToast,
);
expect(setIsDropdownVisible).toHaveBeenCalledWith(false);
expect(dispatch).toHaveBeenCalledTimes(1);
diff --git a/superset-frontend/src/utils/downloadAsImage.test.ts b/superset-frontend/src/utils/downloadAsImage.test.ts
index 1e48b57a6df..30d0cf5326c 100644
--- a/superset-frontend/src/utils/downloadAsImage.test.ts
+++ b/superset-frontend/src/utils/downloadAsImage.test.ts
@@ -18,7 +18,6 @@
*/
import domToImage from 'dom-to-image-more';
import { getInstanceByDom } from 'echarts/core';
-import { addWarningToast } from 'src/components/MessageToasts/actions';
import downloadAsImageOptimized, {
waitForStableScrollHeight,
} from './downloadAsImage';
@@ -33,17 +32,15 @@ jest.mock('echarts/core', () => ({
getInstanceByDom: 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 mockToPng = domToImage.toPng as jest.Mock;
-const mockAddWarningToast = addWarningToast as jest.Mock;
+// Passed explicitly to downloadAsImageOptimized below, mirroring how a real
+// caller supplies an already-bound `useToasts()` callback.
+const mockAddWarningToast = jest.fn();
const mockGetInstanceByDom = getInstanceByDom as jest.Mock;
// document.fonts.ready is not implemented in jsdom; provide a resolved promise
@@ -203,7 +200,14 @@ test('waitForStableScrollHeight resolves if scrollHeight throws (element removed
});
test('shows warning toast when element is not found', async () => {
- const handler = downloadAsImageOptimized('div', 'test');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'test',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
// closest() returning null simulates a selector that matches nothing
await handler({ currentTarget: { closest: () => null } } as any);
@@ -217,7 +221,14 @@ test('shows "still loading" toast when grid has not yet rendered its first rows'
const { container, agContainer, cleanup } = buildAgGridElement();
attachMockApi(agContainer, { firstDataRendered: false });
- const handler = downloadAsImageOptimized('div', 'My Chart');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
await handler(syntheticEventFor(container));
expect(mockAddWarningToast).toHaveBeenCalledWith(
@@ -233,7 +244,14 @@ test('switches to print layout, captures JPEG, and restores normal layout', asyn
const { container, agContainer, cleanup } = buildAgGridElement();
const api = attachMockApi(agContainer);
- const handler = downloadAsImageOptimized('div', 'My Chart');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
@@ -255,7 +273,14 @@ test('restores normal layout in finally even when image capture throws', async (
const { container, agContainer, cleanup } = buildAgGridElement();
const api = attachMockApi(agContainer);
- const handler = downloadAsImageOptimized('div', 'My Chart');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
@@ -275,7 +300,14 @@ test('still captures image when _agGridApi is absent (graceful degradation)', as
// No API — only the first-data-rendered flag
(agContainer as any)._agGridFirstDataRendered = true;
- const handler = downloadAsImageOptimized('div', 'My Chart');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
@@ -311,7 +343,14 @@ test('resolves ag-cell min-height to row pixel height when content fits within i
return Promise.resolve('data:image/jpeg;base64,test');
});
- const handler = downloadAsImageOptimized('div', 'My Chart');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
@@ -352,7 +391,14 @@ test('uses cell scrollHeight when it exceeds row offsetHeight (stale row heights
return Promise.resolve('data:image/jpeg;base64,test');
});
- const handler = downloadAsImageOptimized('div', 'My Chart');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
@@ -387,7 +433,14 @@ test('derives image width from getColumnState by summing visible column pixel wi
},
);
- const handler = downloadAsImageOptimized('div', 'My Chart');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
@@ -412,7 +465,14 @@ test('restores column pixel widths via applyColumnState with flex stripped after
(api as any).getColumnState = jest.fn(() => savedState);
(api as any).applyColumnState = jest.fn();
- const handler = downloadAsImageOptimized('div', 'My Chart');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
@@ -442,7 +502,14 @@ test('restores original column state with flex in finally after capture', async
(api as any).getColumnState = jest.fn(() => savedState);
(api as any).applyColumnState = jest.fn();
- const handler = downloadAsImageOptimized('div', 'My Chart');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
@@ -482,7 +549,14 @@ test('falls back to agRootWrapper.offsetWidth when getColumnState returns no vis
},
);
- const handler = downloadAsImageOptimized('div', 'My Chart');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
@@ -513,7 +587,14 @@ test('restores ag-cell styles after capture even when toJpeg throws', async () =
row.appendChild(cell);
agRootWrapper.appendChild(row);
- const handler = downloadAsImageOptimized('div', 'My Chart');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
@@ -532,7 +613,14 @@ test('calls resetRowHeights after print layout to force ag-grid to re-measure ro
const api = attachMockApi(agContainer);
(api as any).resetRowHeights = jest.fn();
- const handler = downloadAsImageOptimized('div', 'My Chart');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
@@ -550,7 +638,14 @@ test('does not throw when resetRowHeights is absent from the api', async () => {
const { container, agContainer, cleanup } = buildAgGridElement();
attachMockApi(agContainer); // api has only setGridOption, no resetRowHeights
- const handler = downloadAsImageOptimized('div', 'My Chart');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await expect(exportPromise).resolves.toBeUndefined();
@@ -572,7 +667,14 @@ test('falls through to clone path for dashboard export with a single ag-grid cha
dashboard.appendChild(agContainer);
document.body.appendChild(dashboard);
- const handler = downloadAsImageOptimized('.dashboard', 'My Dashboard', true);
+ const handler = downloadAsImageOptimized(
+ '.dashboard',
+ 'My Dashboard',
+ true,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
await handler({ currentTarget: {} } as any);
expect(mockToJpeg).toHaveBeenCalledWith(
@@ -599,7 +701,14 @@ test('falls through to clone path for dashboard export with multiple ag-grid cha
}
document.body.appendChild(dashboard);
- const handler = downloadAsImageOptimized('.dashboard', 'My Dashboard', true);
+ const handler = downloadAsImageOptimized(
+ '.dashboard',
+ 'My Dashboard',
+ true,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
await handler({ currentTarget: {} } as any);
expect(mockToJpeg).toHaveBeenCalledWith(
@@ -615,7 +724,14 @@ 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');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'Bar Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
await handler(syntheticEventFor(container));
expect(mockToJpeg).toHaveBeenCalledWith(
@@ -632,7 +748,14 @@ test('shows warning toast when clone capture throws', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
- const handler = downloadAsImageOptimized('div', 'Bar Chart');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'Bar Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
await handler(syntheticEventFor(container));
expect(mockAddWarningToast).toHaveBeenCalledWith(
@@ -648,7 +771,14 @@ test('ag-grid path uses theme colorBgContainer as background', async () => {
attachMockApi(agContainer);
const theme = { colorBgContainer: '#1a1a2e' } as any;
- const handler = downloadAsImageOptimized('div', 'My Chart', false, theme);
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ false,
+ theme,
+ undefined,
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
@@ -668,10 +798,14 @@ test('ag-grid path exports PNG (transparent) via toPng when format is png', asyn
attachMockApi(agContainer);
const theme = { colorBgContainer: '#1a1a2e' } as any;
- const handler = downloadAsImageOptimized('div', 'My Chart', false, theme, {
- format: 'png',
- backgroundType: 'transparent',
- });
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ false,
+ theme,
+ { format: 'png', backgroundType: 'transparent' },
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
@@ -693,10 +827,14 @@ test('ag-grid path exports PNG (solid) via toPng using theme background', async
attachMockApi(agContainer);
const theme = { colorBgContainer: '#1a1a2e' } as any;
- const handler = downloadAsImageOptimized('div', 'My Chart', false, theme, {
- format: 'png',
- backgroundType: 'solid',
- });
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ false,
+ theme,
+ { format: 'png', backgroundType: 'solid' },
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
@@ -716,7 +854,14 @@ test('ag-grid path falls back to white background when theme is absent', async (
const { container, agContainer, cleanup } = buildAgGridElement();
attachMockApi(agContainer);
- const handler = downloadAsImageOptimized('div', 'My Chart');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'My Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
const exportPromise = handler(syntheticEventFor(container));
await jest.runAllTimersAsync();
await exportPromise;
@@ -734,7 +879,14 @@ 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');
+ const handler = downloadAsImageOptimized(
+ 'div',
+ 'Bar Chart',
+ undefined,
+ undefined,
+ undefined,
+ mockAddWarningToast,
+ );
await handler(syntheticEventFor(container));
expect(mockToJpeg).toHaveBeenCalledWith(
@@ -795,6 +947,7 @@ test('re-renders an ECharts canvas at PNG_SCALE pixel ratio so the export is cri
false,
undefined,
{ format: 'png' },
+ mockAddWarningToast,
);
await handler(syntheticEventFor(container));
@@ -836,6 +989,7 @@ test('preserves a non-ECharts canvas at its on-screen resolution (no re-render)'
false,
undefined,
{ format: 'png' },
+ mockAddWarningToast,
);
await handler(syntheticEventFor(container));
@@ -876,6 +1030,7 @@ test('falls back to a 1:1 copy when the ECharts instance is gone (getInstanceByD
false,
undefined,
{ format: 'png' },
+ mockAddWarningToast,
);
await handler(syntheticEventFor(container));
@@ -920,6 +1075,7 @@ test('falls back to a 1:1 copy (and still exports) when renderToCanvas throws',
false,
undefined,
{ format: 'png' },
+ mockAddWarningToast,
);
await handler(syntheticEventFor(container));
@@ -959,6 +1115,7 @@ test('re-renders an ECharts host only once when it owns multiple canvas layers',
false,
undefined,
{ format: 'png' },
+ mockAddWarningToast,
);
await handler(syntheticEventFor(container));
diff --git a/superset-frontend/src/utils/downloadAsImage.tsx b/superset-frontend/src/utils/downloadAsImage.tsx
index 11d654072ca..623800bb1a6 100644
--- a/superset-frontend/src/utils/downloadAsImage.tsx
+++ b/superset-frontend/src/utils/downloadAsImage.tsx
@@ -21,7 +21,6 @@ import domToImage from 'dom-to-image-more';
import { kebabCase } from 'lodash-es';
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';
@@ -379,6 +378,10 @@ export default function downloadAsImageOptimized(
isExactSelector = false,
theme?: SupersetTheme,
options: DownloadImageOptions = {},
+ // Bound via `useToasts()`/`bindActionCreators`, not the raw action creator
+ // from `actions.ts`: this module has no dispatch of its own, so an unbound
+ // creator would only build a Redux action object and never render a toast.
+ addWarningToast?: (message: string) => void,
) {
const { format = 'jpeg', backgroundType = 'solid' } = options;
@@ -388,7 +391,7 @@ export default function downloadAsImageOptimized(
: event.currentTarget.closest(selector);
if (!elementToPrint) {
- addWarningToast(
+ addWarningToast?.(
t('Image download failed, please refresh and try again.'),
);
return;
@@ -433,7 +436,7 @@ export default function downloadAsImageOptimized(
const isFirstDataRendered = agContainer._agGridFirstDataRendered === true;
if (!isFirstDataRendered) {
- addWarningToast(
+ addWarningToast?.(
t('The chart is still loading. Please wait a moment and try again.'),
);
// This early return skips the capture, so restore virtualization here;
@@ -533,7 +536,7 @@ export default function downloadAsImageOptimized(
link.click();
} catch (error) {
console.error('Creating image failed', error);
- addWarningToast(
+ addWarningToast?.(
t('Image download failed, please refresh and try again.'),
);
} finally {
@@ -614,7 +617,7 @@ export default function downloadAsImageOptimized(
link.click();
} catch (error) {
console.error('Creating image failed', error);
- addWarningToast(
+ addWarningToast?.(
t('Image download failed, please refresh and try again.'),
);
} finally {
diff --git a/superset-frontend/src/utils/downloadAsPdf.test.ts b/superset-frontend/src/utils/downloadAsPdf.test.ts
new file mode 100644
index 00000000000..937f0701e6c
--- /dev/null
+++ b/superset-frontend/src/utils/downloadAsPdf.test.ts
@@ -0,0 +1,66 @@
+/**
+ * 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 downloadAsPdf from './downloadAsPdf';
+
+jest.mock('dom-to-pdf', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+jest.mock('src/utils/getBootstrapData', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({ common: { pdf_compression_level: 'NONE' } })),
+}));
+
+jest.mock('@apache-superset/core/translation', () => ({
+ t: (str: string) => str,
+}));
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+test('warns the user via the bound toast callback and returns early when the target element is not found', async () => {
+ jest.spyOn(document, 'querySelector').mockReturnValue(null);
+ const addWarningToast = jest.fn();
+ const domToPdf = jest.requireMock('dom-to-pdf').default as jest.Mock;
+
+ await downloadAsPdf(
+ '.non-existent-selector',
+ 'test-file',
+ true,
+ addWarningToast,
+ )({} as any);
+
+ // Passed in already bound to dispatch (e.g. via `useToasts()`), so calling
+ // it directly is what actually renders the toast -- unlike the raw action
+ // creator, which only builds a Redux action object.
+ expect(addWarningToast).toHaveBeenCalledWith(
+ 'PDF download failed, please refresh and try again.',
+ );
+ expect(domToPdf).not.toHaveBeenCalled();
+});
+
+test('does not throw when the target element is not found and no toast callback is provided', async () => {
+ jest.spyOn(document, 'querySelector').mockReturnValue(null);
+
+ await expect(
+ downloadAsPdf('.non-existent-selector', 'test-file', true)({} as any),
+ ).resolves.not.toThrow();
+});
diff --git a/superset-frontend/src/utils/downloadAsPdf.ts b/superset-frontend/src/utils/downloadAsPdf.ts
index 7bd452f43a9..27e898da25e 100644
--- a/superset-frontend/src/utils/downloadAsPdf.ts
+++ b/superset-frontend/src/utils/downloadAsPdf.ts
@@ -21,7 +21,6 @@ import domToPdf from 'dom-to-pdf';
import { kebabCase } from 'lodash-es';
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';
@@ -43,12 +42,16 @@ const generateFileStem = (description: string, date = new Date()) =>
* @param description name or a short description of what is being printed.
* Value will be normalized, and a date as well as a file extension will be added.
* @param isExactSelector if false, searches for the closest ancestor that matches selector.
+ * @param addWarningToast bound via `useToasts()`/`bindActionCreators`, not the raw
+ * action creator from `actions.ts`: this module has no dispatch of its own, so an
+ * unbound creator would only build a Redux action object and never render a toast.
* @returns event handler
*/
export default function downloadAsPdf(
selector: string,
description: string,
isExactSelector = false,
+ addWarningToast?: (message: string) => void,
) {
return async (event: SyntheticEvent) => {
const elementToPrint = isExactSelector
@@ -56,9 +59,10 @@ export default function downloadAsPdf(
: event.currentTarget.closest(selector);
if (!elementToPrint) {
- return addWarningToast(
+ addWarningToast?.(
t('PDF download failed, please refresh and try again.'),
);
+ return;
}
// Force any virtualized (unmounted) charts to render before capturing, so