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,
+  };
+};

Reply via email to