This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new d984fb08a45 feat(explore): add download control to standalone charts
(#42238)
d984fb08a45 is described below
commit d984fb08a452618afb0f1ca5920b7270e20d18bb
Author: Rehan Islam <[email protected]>
AuthorDate: Wed Jul 29 10:36:45 2026 +0530
feat(explore): add download control to standalone charts (#42238)
---
superset-frontend/src/constants.ts | 4 +
.../ExploreChartPanel/ExploreChartPanel.test.tsx | 86 +++++++
.../StandaloneDownloadControl.test.tsx | 147 +++++++++++
.../StandaloneDownloadControl.tsx | 108 ++++++++
.../explore/components/ExploreChartPanel/index.tsx | 22 +-
.../useExploreAdditionalActionsMenu/index.tsx | 208 +---------------
.../useExploreDataExport.test.ts | 223 +++++++++++++++++
.../useExploreDataExport.ts | 275 +++++++++++++++++++++
8 files changed, 877 insertions(+), 196 deletions(-)
diff --git a/superset-frontend/src/constants.ts
b/superset-frontend/src/constants.ts
index efd4a516e4c..6e2a5ca64a4 100644
--- a/superset-frontend/src/constants.ts
+++ b/superset-frontend/src/constants.ts
@@ -47,6 +47,10 @@ export const URL_PARAMS = {
name: 'show_filters',
type: 'boolean',
},
+ showDownload: {
+ name: 'show_download',
+ type: 'boolean',
+ },
expandFilters: {
name: 'expand_filters',
type: 'boolean',
diff --git
a/superset-frontend/src/explore/components/ExploreChartPanel/ExploreChartPanel.test.tsx
b/superset-frontend/src/explore/components/ExploreChartPanel/ExploreChartPanel.test.tsx
index e0375f9d67f..ff034b78819 100644
---
a/superset-frontend/src/explore/components/ExploreChartPanel/ExploreChartPanel.test.tsx
+++
b/superset-frontend/src/explore/components/ExploreChartPanel/ExploreChartPanel.test.tsx
@@ -31,6 +31,15 @@ import {
import ChartContainerComponent from 'src/explore/components/ExploreChartPanel';
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
+jest.mock('./StandaloneDownloadControl', () => ({
+ __esModule: true,
+ default: () => (
+ <button type="button" data-test="standalone-download-button">
+ Download
+ </button>
+ ),
+}));
+
// Cast to accept partial mock props in tests
const ChartContainer = ChartContainerComponent as unknown as React.FC<
Record<string, any>
@@ -75,6 +84,10 @@ const createProps = (overrides = {}) => ({
describe('ChartContainer', () => {
jest.setTimeout(10000);
+ afterEach(() => {
+ window.history.replaceState({}, '', window.location.pathname);
+ });
+
test('renders when vizType is line', () => {
const props = createProps();
expect(isValidElement(<ChartContainer {...props} />)).toBe(true);
@@ -188,4 +201,77 @@ describe('ChartContainer', () => {
expect(await screen.findByRole('timer')).toBeInTheDocument();
expect(gutter).not.toBeVisible();
});
+
+ test('does not render standalone download control when show_download is
absent', () => {
+ const props = createProps({
+ standalone: true,
+ can_download: true,
+ });
+
+ render(<ChartContainer {...props} />, { useRedux: true });
+
+ expect(
+ screen.queryByTestId('standalone-download-button'),
+ ).not.toBeInTheDocument();
+ });
+
+ test('does not render standalone download control when show_download is
disabled', () => {
+ window.history.replaceState({}, '', '?show_download=0');
+
+ const props = createProps({
+ standalone: true,
+ can_download: true,
+ });
+
+ render(<ChartContainer {...props} />, { useRedux: true });
+
+ expect(
+ screen.queryByTestId('standalone-download-button'),
+ ).not.toBeInTheDocument();
+ });
+
+ test('renders standalone download control when show_download is enabled and
user can download', () => {
+ window.history.replaceState({}, '', '?show_download=1');
+
+ const props = createProps({
+ standalone: true,
+ can_download: true,
+ });
+
+ render(<ChartContainer {...props} />, { useRedux: true });
+
+ expect(
+ screen.getByTestId('standalone-download-button'),
+ ).toBeInTheDocument();
+ });
+
+ test('does not render standalone download control when user cannot
download', () => {
+ window.history.replaceState({}, '', '?show_download=1');
+
+ const props = createProps({
+ standalone: true,
+ can_download: false,
+ });
+
+ render(<ChartContainer {...props} />, { useRedux: true });
+
+ expect(
+ screen.queryByTestId('standalone-download-button'),
+ ).not.toBeInTheDocument();
+ });
+
+ test('does not render standalone download control outside standalone mode',
() => {
+ window.history.replaceState({}, '', '?show_download=1');
+
+ const props = createProps({
+ standalone: false,
+ can_download: true,
+ });
+
+ render(<ChartContainer {...props} />, { useRedux: true });
+
+ expect(
+ screen.queryByTestId('standalone-download-button'),
+ ).not.toBeInTheDocument();
+ });
});
diff --git
a/superset-frontend/src/explore/components/ExploreChartPanel/StandaloneDownloadControl.test.tsx
b/superset-frontend/src/explore/components/ExploreChartPanel/StandaloneDownloadControl.test.tsx
new file mode 100644
index 00000000000..3e47ffc5e09
--- /dev/null
+++
b/superset-frontend/src/explore/components/ExploreChartPanel/StandaloneDownloadControl.test.tsx
@@ -0,0 +1,147 @@
+/**
+ * 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 { render, screen, userEvent } from 'spec/helpers/testing-library';
+import { VizType } from '@superset-ui/core';
+import { ExportStatus } from
'src/components/StreamingExportModal/StreamingExportModal';
+import { useExploreDataExport } from
'../useExploreAdditionalActionsMenu/useExploreDataExport';
+import StandaloneDownloadControl from './StandaloneDownloadControl';
+
+jest.mock('../useExploreAdditionalActionsMenu/useExploreDataExport', () => ({
+ useExploreDataExport: jest.fn(),
+}));
+
+const mockUseExploreDataExport = useExploreDataExport as jest.MockedFunction<
+ typeof useExploreDataExport
+>;
+
+const exportCSV = jest.fn();
+const exportJson = jest.fn();
+const exportExcel = jest.fn();
+
+const streamingExportState = {
+ isVisible: false,
+ progress: {
+ rowsProcessed: 0,
+ totalRows: undefined,
+ totalSize: 0,
+ speed: 0,
+ mbPerSecond: 0,
+ elapsedTime: 0,
+ status: ExportStatus.STREAMING,
+ },
+ onCancel: jest.fn(),
+ onRetry: jest.fn(),
+ onDownload: jest.fn(),
+};
+
+const latestQueryFormData = {
+ viz_type: VizType.Histogram,
+ datasource: '49__table',
+};
+
+describe('StandaloneDownloadControl', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUseExploreDataExport.mockReturnValue({
+ exportCSV,
+ exportCSVPivoted: jest.fn(),
+ exportJson,
+ exportExcel,
+ handleExportError: jest.fn(),
+ streamingExportState,
+ });
+ });
+
+ test('renders the download button', () => {
+ render(
+ <StandaloneDownloadControl
+ latestQueryFormData={latestQueryFormData}
+ canDownload
+ />,
+ { useRedux: true },
+ );
+
+ expect(
+ screen.getByTestId('standalone-download-button'),
+ ).toBeInTheDocument();
+ });
+
+ test('renders export options when download button is clicked', async () => {
+ render(
+ <StandaloneDownloadControl
+ latestQueryFormData={latestQueryFormData}
+ canDownload
+ />,
+ { useRedux: true },
+ );
+
+ userEvent.click(screen.getByTestId('standalone-download-button'));
+
+ expect(await screen.findByText('Export to .CSV')).toBeInTheDocument();
+ expect(screen.getByText('Export to .JSON')).toBeInTheDocument();
+ expect(screen.getByText('Export to Excel')).toBeInTheDocument();
+ });
+
+ test('exports CSV when CSV option is clicked', async () => {
+ render(
+ <StandaloneDownloadControl
+ latestQueryFormData={latestQueryFormData}
+ canDownload
+ />,
+ { useRedux: true },
+ );
+
+ userEvent.click(screen.getByTestId('standalone-download-button'));
+ userEvent.click(await screen.findByText('Export to .CSV'));
+
+ expect(exportCSV).toHaveBeenCalledTimes(1);
+ });
+
+ test('exports JSON when JSON option is clicked', async () => {
+ render(
+ <StandaloneDownloadControl
+ latestQueryFormData={latestQueryFormData}
+ canDownload
+ />,
+ { useRedux: true },
+ );
+
+ userEvent.click(screen.getByTestId('standalone-download-button'));
+ userEvent.click(await screen.findByText('Export to .JSON'));
+
+ expect(exportJson).toHaveBeenCalledTimes(1);
+ });
+
+ test('exports Excel when Excel option is clicked', async () => {
+ render(
+ <StandaloneDownloadControl
+ latestQueryFormData={latestQueryFormData}
+ canDownload
+ />,
+ { useRedux: true },
+ );
+
+ userEvent.click(screen.getByTestId('standalone-download-button'));
+ userEvent.click(await screen.findByText('Export to Excel'));
+
+ expect(exportExcel).toHaveBeenCalledTimes(1);
+ });
+});
diff --git
a/superset-frontend/src/explore/components/ExploreChartPanel/StandaloneDownloadControl.tsx
b/superset-frontend/src/explore/components/ExploreChartPanel/StandaloneDownloadControl.tsx
new file mode 100644
index 00000000000..33cd41b9fbe
--- /dev/null
+++
b/superset-frontend/src/explore/components/ExploreChartPanel/StandaloneDownloadControl.tsx
@@ -0,0 +1,108 @@
+/**
+ * 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 { useMemo } from 'react';
+import { JsonObject, LatestQueryFormData } from '@superset-ui/core';
+import { Button, Dropdown } from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { t } from '@apache-superset/core/translation';
+import { css, useTheme } from '@apache-superset/core/theme';
+import { StreamingExportModal } from 'src/components/StreamingExportModal';
+import { Slice } from 'src/types/Chart';
+import { useExploreDataExport } from
'../useExploreAdditionalActionsMenu/useExploreDataExport';
+
+interface StandaloneDownloadControlProps {
+ latestQueryFormData: LatestQueryFormData;
+ canDownload: boolean;
+ slice?: Slice;
+ ownState?: JsonObject;
+}
+
+const StandaloneDownloadControl = ({
+ latestQueryFormData,
+ canDownload,
+ slice,
+ ownState,
+}: StandaloneDownloadControlProps) => {
+ const theme = useTheme();
+
+ const { exportCSV, exportJson, exportExcel, streamingExportState } =
+ useExploreDataExport({
+ latestQueryFormData,
+ canDownloadCSV: canDownload,
+ slice,
+ ownState,
+ });
+
+ const downloadMenuItems = useMemo(
+ () => [
+ {
+ key: 'export_csv',
+ label: t('Export to .CSV'),
+ onClick: exportCSV,
+ },
+ {
+ key: 'export_json',
+ label: t('Export to .JSON'),
+ onClick: exportJson,
+ },
+ {
+ key: 'export_excel',
+ label: t('Export to Excel'),
+ onClick: exportExcel,
+ },
+ ],
+ [exportCSV, exportExcel, exportJson],
+ );
+
+ return (
+ <>
+ <Dropdown
+ trigger={['click']}
+ menu={{
+ selectable: false,
+ items: downloadMenuItems,
+ }}
+ >
+ <Button
+ aria-label={t('Download')}
+ data-test="standalone-download-button"
+ css={css`
+ position: absolute;
+ top: ${theme.sizeUnit * 2}px;
+ right: ${theme.sizeUnit * 2}px;
+ z-index: 1;
+ `}
+ >
+ <Icons.DownloadOutlined />
+ </Button>
+ </Dropdown>
+
+ <StreamingExportModal
+ visible={streamingExportState.isVisible}
+ onCancel={streamingExportState.onCancel}
+ onRetry={streamingExportState.onRetry}
+ onDownload={streamingExportState.onDownload}
+ progress={streamingExportState.progress}
+ />
+ </>
+ );
+};
+
+export default StandaloneDownloadControl;
diff --git
a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
index 34ba2d5fc54..832d8f4c1c5 100644
--- a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
+++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx
@@ -32,6 +32,8 @@ import {
getExtensionsRegistry,
} from '@superset-ui/core';
import { Alert } from '@apache-superset/core/components';
+import { URL_PARAMS } from 'src/constants';
+import { getUrlParam } from 'src/utils/urlUtils';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import ChartContainer from 'src/components/Chart/ChartContainer';
import { updateExploreChartState } from 'src/explore/actions/exploreActions';
@@ -56,6 +58,7 @@ import { DataTablesPane } from '../DataTablesPane';
import { ChartPills } from '../ChartPills';
import { ExploreAlert } from '../ExploreAlert';
import useResizeDetectorByObserver from './useResizeDetectorByObserver';
+import StandaloneDownloadControl from './StandaloneDownloadControl';
const extensionsRegistry = getExtensionsRegistry();
const DefaultHeader: React.FC<{ children?: React.ReactNode }> = ({
@@ -177,6 +180,7 @@ const ExploreChartPanel = ({
}: ExploreChartPanelProps) => {
const theme = useTheme();
const dispatch = useDispatch();
+ const showDownload = getUrlParam(URL_PARAMS.showDownload);
const gutterMargin = theme.sizeUnit * GUTTER_SIZE_FACTOR;
const gutterHeight = theme.sizeUnit * GUTTER_SIZE_FACTOR;
@@ -552,7 +556,23 @@ const ExploreChartPanel = ({
document.body.className += ` ${standaloneClass}`;
}
return (
- <div id="app" data-test="standalone-app">
+ <div
+ id="app"
+ data-test="standalone-app"
+ css={css`
+ position: relative;
+ height: 100%;
+ `}
+ >
+ {showDownload && canDownload && (
+ <StandaloneDownloadControl
+ latestQueryFormData={chart.latestQueryFormData}
+ canDownload={canDownload}
+ slice={slice}
+ ownState={mergedOwnState}
+ />
+ )}
+
{standaloneChartBody}
</div>
);
diff --git
a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
index 9b21454b467..8255e6b5616 100644
---
a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
+++
b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
@@ -46,7 +46,6 @@ import {
} from '@superset-ui/core/components';
import { Menu, MenuProps } from '@superset-ui/core/components/Menu';
import { useToasts } from 'src/components/MessageToasts/withToasts';
-import { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants';
import { exportChart, getChartKey } from 'src/explore/exploreUtils';
import downloadAsImage from 'src/utils/downloadAsImage';
import downloadAsPdf from 'src/utils/downloadAsPdf';
@@ -65,16 +64,14 @@ import {
LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS,
} from 'src/logger/LogUtils';
import exportPivotExcel from 'src/utils/downloadAsPivotExcel';
-import {
- useStreamingExport,
- StreamingProgress,
-} from 'src/components/StreamingExportModal';
+import { StreamingProgress } from 'src/components/StreamingExportModal';
import { Slice } from 'src/types/Chart';
import { ChartState, ExplorePageInitialData } from 'src/explore/types';
import { ReportObject } from 'src/features/reports/types';
import ViewQueryModal from '../controls/ViewQueryModal';
import EmbedCodeContent from '../EmbedCodeContent';
import { useDashboardsMenuItems } from './DashboardsSubMenu';
+import { useExploreDataExport } from './useExploreDataExport';
export const SEARCH_THRESHOLD = 10;
@@ -317,11 +314,6 @@ export const useExploreAdditionalActionsMenu = (
const chart = useSelector<ExploreState, ChartState | undefined>(state =>
state.explore ? state.charts?.[getChartKey(state.explore)] : undefined,
);
- const streamingThreshold = useSelector<ExploreState, number>(
- state =>
- state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD ||
- DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
- );
const exploreChartState = useSelector<ExploreState, JsonObject | undefined>(
state => {
const chartKey = state.explore ? getChartKey(state.explore) : undefined;
@@ -362,32 +354,20 @@ export const useExploreAdditionalActionsMenu = (
);
// Streaming export state and handlers
- const [isStreamingModalVisible, setIsStreamingModalVisible] =
useState(false);
const {
- progress,
- isExporting: _isExporting,
- startExport,
- cancelExport: _cancelExport,
- resetExport,
- retryExport,
- } = useStreamingExport({
- onComplete: () => {
- // Don't show toast here - wait for user to click Download button
- },
- onError: () => {
- addDangerToast(t('Export failed - please try again'));
- },
+ exportCSV,
+ exportCSVPivoted,
+ exportJson,
+ exportExcel,
+ handleExportError,
+ streamingExportState,
+ } = useExploreDataExport({
+ latestQueryFormData,
+ canDownloadCSV,
+ slice,
+ ownState,
});
- const handleCloseStreamingModal = useCallback(() => {
- setIsStreamingModalVisible(false);
- resetExport();
- }, [resetExport]);
-
- const handleDownloadComplete = useCallback(() => {
- addSuccessToast(t('CSV file downloaded successfully'));
- }, [addSuccessToast]);
-
// Use the updated report menu items hook
const reportMenuItem = useHeaderReportMenuItems({
chart,
@@ -439,159 +419,6 @@ export const useExploreAdditionalActionsMenu = (
}
}, [addDangerToast, latestQueryFormData, permalinkChartState]);
- const handleExportError = useCallback(
- (error: unknown) => {
- const exportError = error as Error & {
- status?: number;
- statusText?: string;
- response?: { status?: number };
- };
- const status = exportError.status || exportError.response?.status;
- if (status === 413) {
- addDangerToast(
- t(
- 'The chart data is too large to download. Please try reducing the
date range, limiting rows, or using fewer columns.',
- ),
- );
- } else {
- const errorMessage =
- exportError.message ||
- exportError.statusText ||
- t(
- 'Failed to export chart data. Please try again or contact your
administrator.',
- );
- addDangerToast(errorMessage);
- }
- },
- [addDangerToast],
- );
-
- const exportCSV = useCallback(async () => {
- if (!canDownloadCSV) return null;
-
- // Determine row count for streaming threshold check
- let actualRowCount;
- const isTableViz = latestQueryFormData?.viz_type === 'table';
- const queriesResponse = chart?.queriesResponse;
-
- if (
- isTableViz &&
- queriesResponse &&
- queriesResponse.length > 1 &&
- queriesResponse[1]?.data?.[0]?.rowcount
- ) {
- actualRowCount = queriesResponse[1].data[0].rowcount;
- } else if (queriesResponse && queriesResponse[0]?.sql_rowcount != null) {
- actualRowCount = queriesResponse[0].sql_rowcount;
- } else if (queriesResponse && queriesResponse[0]?.rowcount != null) {
- actualRowCount = queriesResponse[0].rowcount;
- } else {
- actualRowCount = latestQueryFormData?.row_limit;
- }
-
- // Check if streaming should be used
- const shouldUseStreaming =
- actualRowCount && actualRowCount >= streamingThreshold;
-
- let filename: string | undefined;
- if (shouldUseStreaming) {
- const now = new Date();
- const date = now.toISOString().slice(0, 10);
- const time = now.toISOString().slice(11, 19).replace(/:/g, '');
- const timestamp = `_${date}_${time}`;
- const chartName =
- slice?.slice_name || latestQueryFormData.viz_type || 'chart';
- const safeChartName = chartName.replace(/[^a-zA-Z0-9_-]/g, '_');
- filename = `${safeChartName}${timestamp}.csv`;
- }
-
- try {
- await exportChart({
- formData: latestQueryFormData as QueryFormData,
- ownState,
- resultType: 'full',
- resultFormat: 'csv',
- onStartStreamingExport: shouldUseStreaming
- ? exportParams => {
- if (exportParams.url) {
- setIsStreamingModalVisible(true);
- startExport({
- ...exportParams,
- url: exportParams.url,
- filename,
- expectedRows: actualRowCount,
- exportType: exportParams.exportType as 'csv' | 'xlsx',
- });
- }
- }
- : null,
- });
- } catch (error) {
- handleExportError(error);
- }
- return null;
- }, [
- canDownloadCSV,
- latestQueryFormData,
- ownState,
- chart,
- streamingThreshold,
- slice,
- startExport,
- handleExportError,
- ]);
-
- const exportCSVPivoted = useCallback(async () => {
- if (!canDownloadCSV) {
- return null;
- }
- try {
- await exportChart({
- formData: latestQueryFormData as QueryFormData,
- ownState,
- resultType: 'post_processed',
- resultFormat: 'csv',
- });
- } catch (error) {
- handleExportError(error);
- }
- return null;
- }, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
-
- const exportJson = useCallback(async () => {
- if (!canDownloadCSV) {
- return null;
- }
- try {
- await exportChart({
- formData: latestQueryFormData as QueryFormData,
- ownState,
- resultType: 'results',
- resultFormat: 'json',
- });
- } catch (error) {
- handleExportError(error);
- }
- return null;
- }, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
-
- const exportExcel = useCallback(async () => {
- if (!canDownloadCSV) {
- return null;
- }
- try {
- await exportChart({
- formData: latestQueryFormData as QueryFormData,
- ownState,
- resultType: 'results',
- resultFormat: 'xlsx',
- });
- } catch (error) {
- handleExportError(error);
- }
- return null;
- }, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
-
const copyLink = useCallback(async () => {
try {
if (!latestQueryFormData?.datasource) {
@@ -1246,14 +1073,5 @@ export const useExploreAdditionalActionsMenu = (
canExportImage,
]);
- // Return streaming modal state and handlers for parent to render
- const streamingExportState = {
- isVisible: isStreamingModalVisible,
- progress,
- onCancel: handleCloseStreamingModal,
- onRetry: retryExport,
- onDownload: handleDownloadComplete,
- };
-
return [menu, isDropdownVisible, setIsDropdownVisible, streamingExportState];
};
diff --git
a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.test.ts
b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.test.ts
new file mode 100644
index 00000000000..451fb2adc98
--- /dev/null
+++
b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.test.ts
@@ -0,0 +1,223 @@
+/**
+ * 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 { act, renderHook } from '@testing-library/react';
+import * as exploreUtils from 'src/explore/exploreUtils';
+import { useExploreDataExport } from './useExploreDataExport';
+
+const mockAddDangerToast = jest.fn();
+const mockAddSuccessToast = jest.fn();
+const mockStartExport = jest.fn();
+const mockResetExport = jest.fn();
+const mockRetryExport = jest.fn();
+
+jest.mock('src/components/MessageToasts/withToasts', () => ({
+ useToasts: () => ({
+ addDangerToast: mockAddDangerToast,
+ addSuccessToast: mockAddSuccessToast,
+ }),
+}));
+
+jest.mock('src/components/StreamingExportModal', () => ({
+ useStreamingExport: jest.fn(() => ({
+ progress: {
+ rowsProcessed: 0,
+ totalRows: undefined,
+ totalSize: 0,
+ speed: 0,
+ mbPerSecond: 0,
+ elapsedTime: 0,
+ status: 'streaming',
+ },
+ startExport: mockStartExport,
+ resetExport: mockResetExport,
+ retryExport: mockRetryExport,
+ })),
+}));
+
+jest.mock('src/explore/exploreUtils', () => ({
+ __esModule: true,
+ ...jest.requireActual('src/explore/exploreUtils'),
+ exportChart: jest.fn(),
+ getChartKey: jest.fn(() => 1),
+}));
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest.fn(callback =>
+ callback({
+ charts: {
+ 1: {
+ queriesResponse: [{ sql_rowcount: 10 }],
+ },
+ },
+ explore: {
+ slice: {
+ slice_id: 1,
+ slice_name: 'Test Chart',
+ },
+ },
+ common: {
+ conf: {
+ CSV_STREAMING_ROW_THRESHOLD: 1000,
+ },
+ },
+ }),
+ ),
+}));
+
+const mockExportChart = exploreUtils.exportChart as jest.Mock;
+
+const defaultProps = {
+ latestQueryFormData: {
+ datasource: '1__table',
+ viz_type: 'table',
+ row_limit: 10000,
+ } as any,
+ canDownloadCSV: true,
+ slice: {
+ slice_id: 1,
+ slice_name: 'Test Chart',
+ } as any,
+ ownState: {},
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockExportChart.mockResolvedValue(undefined);
+});
+
+test('exports CSV with full result type', async () => {
+ const { result } = renderHook(() => useExploreDataExport(defaultProps));
+
+ await act(async () => {
+ await result.current.exportCSV();
+ });
+
+ expect(mockExportChart).toHaveBeenCalledWith(
+ expect.objectContaining({
+ formData: defaultProps.latestQueryFormData,
+ ownState: defaultProps.ownState,
+ resultType: 'full',
+ resultFormat: 'csv',
+ }),
+ );
+});
+
+test('exports pivoted CSV with post processed result type', async () => {
+ const { result } = renderHook(() => useExploreDataExport(defaultProps));
+
+ await act(async () => {
+ await result.current.exportCSVPivoted();
+ });
+
+ expect(mockExportChart).toHaveBeenCalledWith({
+ formData: defaultProps.latestQueryFormData,
+ ownState: defaultProps.ownState,
+ resultType: 'post_processed',
+ resultFormat: 'csv',
+ });
+});
+
+test('exports JSON with results result type', async () => {
+ const { result } = renderHook(() => useExploreDataExport(defaultProps));
+
+ await act(async () => {
+ await result.current.exportJson();
+ });
+
+ expect(mockExportChart).toHaveBeenCalledWith({
+ formData: defaultProps.latestQueryFormData,
+ ownState: defaultProps.ownState,
+ resultType: 'results',
+ resultFormat: 'json',
+ });
+});
+
+test('exports Excel with results result type', async () => {
+ const { result } = renderHook(() => useExploreDataExport(defaultProps));
+
+ await act(async () => {
+ await result.current.exportExcel();
+ });
+
+ expect(mockExportChart).toHaveBeenCalledWith({
+ formData: defaultProps.latestQueryFormData,
+ ownState: defaultProps.ownState,
+ resultType: 'results',
+ resultFormat: 'xlsx',
+ });
+});
+
+test('does not export when downloads are disabled', async () => {
+ const { result } = renderHook(() =>
+ useExploreDataExport({
+ ...defaultProps,
+ canDownloadCSV: false,
+ }),
+ );
+
+ await act(async () => {
+ await result.current.exportCSV();
+ await result.current.exportCSVPivoted();
+ await result.current.exportJson();
+ await result.current.exportExcel();
+ });
+
+ expect(mockExportChart).not.toHaveBeenCalled();
+});
+
+test('shows large data error toast when export fails with status 413', async
() => {
+ mockExportChart.mockRejectedValue({
+ status: 413,
+ });
+
+ const { result } = renderHook(() => useExploreDataExport(defaultProps));
+
+ await act(async () => {
+ await result.current.exportCSV();
+ });
+
+ expect(mockAddDangerToast).toHaveBeenCalledWith(
+ expect.stringContaining('too large to download'),
+ );
+});
+
+test('shows export error message when export fails', async () => {
+ mockExportChart.mockRejectedValue(new Error('Export exploded'));
+
+ const { result } = renderHook(() => useExploreDataExport(defaultProps));
+
+ await act(async () => {
+ await result.current.exportJson();
+ });
+
+ expect(mockAddDangerToast).toHaveBeenCalledWith('Export exploded');
+});
+
+test('exposes streaming export modal state and handlers', () => {
+ const { result } = renderHook(() => useExploreDataExport(defaultProps));
+
+ expect(result.current.streamingExportState).toEqual(
+ expect.objectContaining({
+ isVisible: false,
+ onRetry: mockRetryExport,
+ }),
+ );
+});
diff --git
a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts
b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts
new file mode 100644
index 00000000000..0aa90cffbd8
--- /dev/null
+++
b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/useExploreDataExport.ts
@@ -0,0 +1,275 @@
+/**
+ * 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 { useCallback, useState } from 'react';
+import { useSelector } from 'react-redux';
+import {
+ JsonObject,
+ LatestQueryFormData,
+ QueryFormData,
+} from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { useToasts } from 'src/components/MessageToasts/withToasts';
+import {
+ StreamingProgress,
+ useStreamingExport,
+} from 'src/components/StreamingExportModal';
+import { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants';
+import { exportChart, getChartKey } from 'src/explore/exploreUtils';
+import { ChartState } from 'src/explore/types';
+import { Slice } from 'src/types/Chart';
+
+interface ExploreSlice {
+ slice?: Slice | null;
+}
+
+interface ExploreState {
+ charts?: Record<number, ChartState>;
+ explore?: ExploreSlice;
+ common?: {
+ conf?: {
+ CSV_STREAMING_ROW_THRESHOLD?: number;
+ };
+ };
+}
+
+export interface StreamingExportState {
+ isVisible: boolean;
+ progress: StreamingProgress;
+ onCancel: () => void;
+ onRetry: () => void;
+ onDownload: () => void;
+}
+
+interface UseExploreDataExportProps {
+ latestQueryFormData: LatestQueryFormData;
+ canDownloadCSV: boolean;
+ slice?: Slice | null;
+ ownState?: JsonObject;
+}
+
+export const useExploreDataExport = ({
+ latestQueryFormData,
+ canDownloadCSV,
+ slice,
+ ownState,
+}: UseExploreDataExportProps) => {
+ const { addDangerToast, addSuccessToast } = useToasts();
+
+ const chart = useSelector<ExploreState, ChartState | undefined>(state =>
+ state.explore ? state.charts?.[getChartKey(state.explore)] : undefined,
+ );
+
+ const streamingThreshold = useSelector<ExploreState, number>(
+ state =>
+ state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD ||
+ DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
+ );
+
+ const [isStreamingModalVisible, setIsStreamingModalVisible] =
useState(false);
+
+ const { progress, startExport, resetExport, retryExport } =
+ useStreamingExport({
+ onComplete: () => {
+ // Wait for the user to click Download before showing a success toast.
+ },
+ onError: () => {
+ addDangerToast(t('Export failed - please try again'));
+ },
+ });
+
+ const handleCloseStreamingModal = useCallback(() => {
+ setIsStreamingModalVisible(false);
+ resetExport();
+ }, [resetExport]);
+
+ const handleDownloadComplete = useCallback(() => {
+ addSuccessToast(t('CSV file downloaded successfully'));
+ }, [addSuccessToast]);
+
+ const handleExportError = useCallback(
+ (error: unknown) => {
+ const exportError = error as Error & {
+ status?: number;
+ statusText?: string;
+ response?: { status?: number };
+ };
+ const status = exportError.status || exportError.response?.status;
+
+ if (status === 413) {
+ addDangerToast(
+ t(
+ 'The chart data is too large to download. Please try reducing the
date range, limiting rows, or using fewer columns.',
+ ),
+ );
+ } else {
+ const errorMessage =
+ exportError.message ||
+ exportError.statusText ||
+ t(
+ 'Failed to export chart data. Please try again or contact your
administrator.',
+ );
+ addDangerToast(errorMessage);
+ }
+ },
+ [addDangerToast],
+ );
+
+ const exportCSV = useCallback(async () => {
+ if (!canDownloadCSV) return null;
+
+ let actualRowCount;
+ const isTableViz = latestQueryFormData?.viz_type === 'table';
+ const queriesResponse = chart?.queriesResponse;
+
+ if (
+ isTableViz &&
+ queriesResponse &&
+ queriesResponse.length > 1 &&
+ queriesResponse[1]?.data?.[0]?.rowcount
+ ) {
+ actualRowCount = queriesResponse[1].data[0].rowcount;
+ } else if (queriesResponse && queriesResponse[0]?.sql_rowcount != null) {
+ actualRowCount = queriesResponse[0].sql_rowcount;
+ } else if (queriesResponse && queriesResponse[0]?.rowcount != null) {
+ actualRowCount = queriesResponse[0].rowcount;
+ } else {
+ actualRowCount = latestQueryFormData?.row_limit;
+ }
+
+ const shouldUseStreaming =
+ actualRowCount && actualRowCount >= streamingThreshold;
+
+ let filename: string | undefined;
+ if (shouldUseStreaming) {
+ const now = new Date();
+ const date = now.toISOString().slice(0, 10);
+ const time = now.toISOString().slice(11, 19).replace(/:/g, '');
+ const timestamp = `_${date}_${time}`;
+ const chartName =
+ slice?.slice_name || latestQueryFormData.viz_type || 'chart';
+ const safeChartName = chartName.replace(/[^a-zA-Z0-9_-]/g, '_');
+ filename = `${safeChartName}${timestamp}.csv`;
+ }
+
+ try {
+ await exportChart({
+ formData: latestQueryFormData as QueryFormData,
+ ownState,
+ resultType: 'full',
+ resultFormat: 'csv',
+ onStartStreamingExport: shouldUseStreaming
+ ? exportParams => {
+ if (exportParams.url) {
+ setIsStreamingModalVisible(true);
+ startExport({
+ ...exportParams,
+ url: exportParams.url,
+ filename,
+ expectedRows: actualRowCount,
+ exportType: exportParams.exportType as 'csv' | 'xlsx',
+ });
+ }
+ }
+ : null,
+ });
+ } catch (error) {
+ handleExportError(error);
+ }
+
+ return null;
+ }, [
+ canDownloadCSV,
+ latestQueryFormData,
+ ownState,
+ chart,
+ streamingThreshold,
+ slice,
+ startExport,
+ handleExportError,
+ ]);
+
+ const exportCSVPivoted = useCallback(async () => {
+ if (!canDownloadCSV) return null;
+
+ try {
+ await exportChart({
+ formData: latestQueryFormData as QueryFormData,
+ ownState,
+ resultType: 'post_processed',
+ resultFormat: 'csv',
+ });
+ } catch (error) {
+ handleExportError(error);
+ }
+
+ return null;
+ }, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
+
+ const exportJson = useCallback(async () => {
+ if (!canDownloadCSV) return null;
+
+ try {
+ await exportChart({
+ formData: latestQueryFormData as QueryFormData,
+ ownState,
+ resultType: 'results',
+ resultFormat: 'json',
+ });
+ } catch (error) {
+ handleExportError(error);
+ }
+
+ return null;
+ }, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
+
+ const exportExcel = useCallback(async () => {
+ if (!canDownloadCSV) return null;
+
+ try {
+ await exportChart({
+ formData: latestQueryFormData as QueryFormData,
+ ownState,
+ resultType: 'results',
+ resultFormat: 'xlsx',
+ });
+ } catch (error) {
+ handleExportError(error);
+ }
+
+ return null;
+ }, [canDownloadCSV, latestQueryFormData, ownState, handleExportError]);
+
+ const streamingExportState: StreamingExportState = {
+ isVisible: isStreamingModalVisible,
+ progress,
+ onCancel: handleCloseStreamingModal,
+ onRetry: retryExport,
+ onDownload: handleDownloadComplete,
+ };
+
+ return {
+ exportCSV,
+ exportCSVPivoted,
+ exportJson,
+ exportExcel,
+ handleExportError,
+ streamingExportState,
+ };
+};