diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx
index 0f840d87430..79c41f30aa1 100644
--- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx
+++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx
@@ -40,6 +40,19 @@ jest.mock('src/dashboard/util/isEmbedded', () => ({
isEmbedded: jest.fn(() => false),
}));
+jest.mock('src/explore/exploreUtils', () => {
+ const actual = jest.requireActual('src/explore/exploreUtils');
+ return {
+ ...actual,
+ exportChart: jest.fn(),
+ };
+});
+
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const { exportChart: exportChartMock } = jest.requireMock(
+ 'src/explore/exploreUtils',
+) as { exportChart: jest.Mock };
+
const CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*';
const FORM_DATA_KEY_ENDPOINT = 'glob:*/api/v1/explore/form_data';
@@ -553,4 +566,64 @@ describe('Table view with pagination', () => {
// Table should still be rendered without crashes
expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument();
});
+
+ test('CSV download calls exportChart with drilledFormData', async () => {
+ exportChartMock.mockClear();
+ await renderModal({
+ column: { column_name: 'state', verbose_name: null },
+ drillByConfig: {
+ filters: [{ col: 'gender', op: '==', val: 'boy' }],
+ groupbyFieldName: 'groupby',
+ },
+ });
+
+ const tableRadio = await screen.findByRole('radio', { name: /table/i });
+ userEvent.click(tableRadio);
+ await waitFor(() =>
+ expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument(),
+ );
+
+ await userEvent.click(
+ await screen.findByRole('button', { name: 'Download' }),
+ );
+ await userEvent.click(await screen.findByText('Export to CSV'));
+
+ expect(exportChartMock).toHaveBeenCalledTimes(1);
+ expect(exportChartMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ resultFormat: 'csv',
+ resultType: 'full',
+ }),
+ );
+ });
+
+ test('XLSX download calls exportChart with xlsx format', async () => {
+ exportChartMock.mockClear();
+ await renderModal({
+ column: { column_name: 'state', verbose_name: null },
+ drillByConfig: {
+ filters: [{ col: 'gender', op: '==', val: 'boy' }],
+ groupbyFieldName: 'groupby',
+ },
+ });
+
+ const tableRadio = await screen.findByRole('radio', { name: /table/i });
+ userEvent.click(tableRadio);
+ await waitFor(() =>
+ expect(screen.getByTestId('drill-by-results-table')).toBeInTheDocument(),
+ );
+
+ await userEvent.click(
+ await screen.findByRole('button', { name: 'Download' }),
+ );
+ await userEvent.click(await screen.findByText('Export to Excel'));
+
+ expect(exportChartMock).toHaveBeenCalledTimes(1);
+ expect(exportChartMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ resultFormat: 'xlsx',
+ resultType: 'full',
+ }),
+ );
+ });
});
diff --git a/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.test.tsx
new file mode 100644
index 00000000000..f09c364dcbc
--- /dev/null
+++ b/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.test.tsx
@@ -0,0 +1,57 @@
+/**
+ * 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 DownloadDropdown from './DownloadDropdown';
+
+const onDownloadCSV = jest.fn();
+const onDownloadXLSX = jest.fn();
+
+beforeEach(() => {
+ onDownloadCSV.mockClear();
+ onDownloadXLSX.mockClear();
+});
+
+const setup = () =>
+ render(
+ ,
+ );
+
+test('renders a download trigger with accessible label', () => {
+ setup();
+ expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument();
+});
+
+test('fires onDownloadCSV when CSV menu item is selected', async () => {
+ setup();
+ await userEvent.click(screen.getByRole('button', { name: 'Download' }));
+ await userEvent.click(await screen.findByText('Export to CSV'));
+ expect(onDownloadCSV).toHaveBeenCalledTimes(1);
+ expect(onDownloadXLSX).not.toHaveBeenCalled();
+});
+
+test('fires onDownloadXLSX when Excel menu item is selected', async () => {
+ setup();
+ await userEvent.click(screen.getByRole('button', { name: 'Download' }));
+ await userEvent.click(await screen.findByText('Export to Excel'));
+ expect(onDownloadXLSX).toHaveBeenCalledTimes(1);
+ expect(onDownloadCSV).not.toHaveBeenCalled();
+});
diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx
index 030552823ec..f9d9b924878 100644
--- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx
+++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx
@@ -18,7 +18,12 @@
*/
import fetchMock from 'fetch-mock';
import { QueryFormData, SupersetClient } from '@superset-ui/core';
-import { render, screen, waitFor } from 'spec/helpers/testing-library';
+import {
+ render,
+ screen,
+ userEvent,
+ waitFor,
+} from 'spec/helpers/testing-library';
import { getMockStoreWithNativeFilters } from 'spec/fixtures/mockStore';
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
import { supersetGetCache } from 'src/utils/cachedSupersetGet';
@@ -189,6 +194,66 @@ test('should render the error', async () => {
expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument();
});
+describe('download actions', () => {
+ const renderWithDownloadPermission = () =>
+ render(
+ ,
+ {
+ useRedux: true,
+ initialState: {
+ user: { roles: { Admin: [['can_csv', 'Superset']] } },
+ common: { conf: { SAMPLES_ROW_LIMIT: 10, ROW_LIMIT: 50000 } },
+ dashboardInfo: { id: 123 },
+ },
+ },
+ );
+
+ const clickDownloadItem = async (label: string) => {
+ await userEvent.click(
+ await screen.findByRole('button', { name: 'Download' }),
+ );
+ await userEvent.click(await screen.findByText(label));
+ };
+
+ test('CSV export posts drill_detail payload with ROW_LIMIT', async () => {
+ fetchWithData();
+ const postFormSpy = jest
+ .spyOn(SupersetClient, 'postForm')
+ .mockImplementation(() => Promise.resolve());
+ renderWithDownloadPermission();
+
+ await clickDownloadItem('Export to CSV');
+
+ expect(postFormSpy).toHaveBeenCalledTimes(1);
+ const body = postFormSpy.mock.calls[0][1] as { form_data: string };
+ const payload = JSON.parse(body.form_data);
+ expect(payload.result_type).toBe('drill_detail');
+ expect(payload.result_format).toBe('csv');
+ expect(payload.queries[0].row_limit).toBe(50000);
+ expect(payload.form_data.dashboardId).toBe(123);
+ postFormSpy.mockRestore();
+ });
+
+ test('XLSX export uses xlsx result_format', async () => {
+ fetchWithData();
+ const postFormSpy = jest
+ .spyOn(SupersetClient, 'postForm')
+ .mockImplementation(() => Promise.resolve());
+ renderWithDownloadPermission();
+
+ await clickDownloadItem('Export to Excel');
+
+ expect(postFormSpy).toHaveBeenCalledTimes(1);
+ const body = postFormSpy.mock.calls[0][1] as { form_data: string };
+ const payload = JSON.parse(body.form_data);
+ expect(payload.result_format).toBe('xlsx');
+ postFormSpy.mockRestore();
+ });
+});
+
test('should use verbose_map for column headers when available', async () => {
jest.restoreAllMocks();