From fa31ec8cb3ab2386e5d004689c6efa8f65a766c3 Mon Sep 17 00:00:00 2001 From: alex-poor Date: Tue, 14 Apr 2026 22:16:25 +1200 Subject: [PATCH] feat(drill-detail): add CSV/XLSX download + reload to drill modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto fresh upstream master. Upstream substantially refactored the DataTables / GridTable surface since this PR first opened, so the previous 14 fix-commits have been collapsed into one clean change on top of current master. Scope (narrowed from the original PR): only the two drill modals are affected. The Explore pane Results/Samples tabs are left as-is since upstream moved them toward a different row-limit-selector pattern. Layout matches the last-approved maintainer review: RowCount → Download dropdown (CSV/XLSX) → Copy → Reload, inside the existing action bar on both drill modals. No changes to icon position or aesthetic from the previously approved iteration. Drill to Detail: - DrillDetailPane owns download logic, uses ROW_LIMIT (not SAMPLES_ROW_LIMIT) via /api/v1/chart/data with result_type=drill_detail. - dashboardId included in form_data for guest/embedded auth. - usePermissions() used so GRANULAR_EXPORT_CONTROLS is respected. Drill By: - DrillByModal uses exportChart with the current drilledFormData. - Reload re-fetches chart data via getChartDataRequest. - Handlers are passed through useResultsTableView → SingleQueryResultPane → DataTableControls (props made optional so explore panes are unchanged). Supporting changes: - New DownloadDropdown shared component (icon + CSV/XLSX dropdown). - CopyToClipboardButton icon now uses theme.colorIcon, wrapped in a span so CopyToClipboard's cloneElement doesn't strip the style (keeps icon colour consistent with download/reload). - ROW_LIMIT added to the list of configs exposed to the frontend. Co-Authored-By: Claude Opus 4.6 --- .../components/Chart/DrillBy/DrillByModal.tsx | 59 +++++++++++-- .../Chart/DrillBy/useResultsTableView.tsx | 9 ++ .../Chart/DrillDetail/DownloadDropdown.tsx | 82 +++++++++++++++++++ .../Chart/DrillDetail/DrillDetailPane.tsx | 75 +++++++++++++++++ .../DrillDetailTableControls.stories.tsx | 5 ++ .../DrillDetailTableControls.test.tsx | 9 +- .../DrillDetail/DrillDetailTableControls.tsx | 39 +++++++-- .../components/DataTableControl/index.tsx | 61 ++++++++------ .../components/DataTableControls.tsx | 28 ++++++- .../components/SingleQueryResultPane.tsx | 6 ++ .../components/DataTablesPane/types.ts | 6 ++ superset/views/base.py | 1 + 12 files changed, 336 insertions(+), 44 deletions(-) create mode 100644 superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.tsx diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx index 1bb63d0d81e..494cd7c79d9 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.tsx @@ -54,7 +54,7 @@ import { LOG_ACTIONS_FURTHER_DRILL_BY, } from 'src/logger/LogUtils'; import { findPermission } from 'src/utils/findPermission'; -import { getQuerySettings } from 'src/explore/exploreUtils'; +import { getQuerySettings, exportChart } from 'src/explore/exploreUtils'; import { isEmbedded } from 'src/dashboard/util/isEmbedded'; import { Dataset, DrillByType } from '../types'; import DrillByChart from './DrillByChart'; @@ -209,12 +209,6 @@ export default function DrillByModal({ const { displayModeToggle, drillByDisplayMode } = useDisplayModeToggle(); const [chartDataResult, setChartDataResult] = useState(); - const resultsTable = useResultsTableView( - chartDataResult, - formData.datasource, - canDownload, - ); - const [currentFormData, setCurrentFormData] = useState(formData); const [usedGroupbyColumns, setUsedGroupbyColumns] = useState( [...initialGroupbyColumns, column].filter(isDefined), @@ -377,6 +371,57 @@ export default function DrillByModal({ formData, ]); + const handleDownload = useCallback( + (exportType: 'csv' | 'xlsx') => { + exportChart({ + formData: drilledFormData, + resultFormat: exportType, + resultType: 'full', + }); + }, + [drilledFormData], + ); + + const handleDownloadCSV = useCallback( + () => handleDownload('csv'), + [handleDownload], + ); + + const handleDownloadXLSX = useCallback( + () => handleDownload('xlsx'), + [handleDownload], + ); + + const handleReload = useCallback(() => { + setChartDataResult(undefined); + setIsChartDataLoading(true); + const [useLegacyApi] = getQuerySettings(drilledFormData); + getChartDataRequest({ + formData: drilledFormData, + }) + .then(({ response, json }) => + handleChartDataResponse(response, json, useLegacyApi), + ) + .then(queriesResponse => { + setChartDataResult(queriesResponse); + }) + .catch(() => { + addDangerToast(t('Failed to load chart data.')); + }) + .finally(() => { + setIsChartDataLoading(false); + }); + }, [addDangerToast, drilledFormData]); + + const resultsTable = useResultsTableView( + chartDataResult, + formData.datasource, + canDownload, + handleDownloadCSV, + handleDownloadXLSX, + handleReload, + ); + useEffect(() => { setUsedGroupbyColumns(usedCols => !currentColumn || diff --git a/superset-frontend/src/components/Chart/DrillBy/useResultsTableView.tsx b/superset-frontend/src/components/Chart/DrillBy/useResultsTableView.tsx index 4664e372f32..b36ef4a992c 100644 --- a/superset-frontend/src/components/Chart/DrillBy/useResultsTableView.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/useResultsTableView.tsx @@ -35,6 +35,9 @@ export const useResultsTableView = ( chartDataResult: QueryData[] | undefined, datasourceId: string, canDownload: boolean, + onDownloadCSV?: () => void, + onDownloadXLSX?: () => void, + onReload?: () => void, ) => { if (!isDefined(chartDataResult)) { return
; @@ -50,6 +53,9 @@ export const useResultsTableView = ( datasourceId={datasourceId} isVisible canDownload={canDownload} + onDownloadCSV={onDownloadCSV} + onDownloadXLSX={onDownloadXLSX} + onReload={onReload} /> ); @@ -70,6 +76,9 @@ export const useResultsTableView = ( datasourceId={datasourceId} isVisible canDownload={canDownload} + onDownloadCSV={onDownloadCSV} + onDownloadXLSX={onDownloadXLSX} + onReload={onReload} /> ), diff --git a/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.tsx b/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.tsx new file mode 100644 index 00000000000..4ebaa3be7e8 --- /dev/null +++ b/superset-frontend/src/components/Chart/DrillDetail/DownloadDropdown.tsx @@ -0,0 +1,82 @@ +/** + * 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 { t } from '@apache-superset/core/translation'; +import { css, useTheme } from '@apache-superset/core/theme'; +import { Dropdown, Tooltip } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; + +interface DownloadDropdownProps { + onDownloadCSV: () => void; + onDownloadXLSX: () => void; +} + +const DownloadDropdown = ({ + onDownloadCSV, + onDownloadXLSX, +}: DownloadDropdownProps) => { + const theme = useTheme(); + return ( + { + if (key === 'csv') { + onDownloadCSV(); + } else if (key === 'xlsx') { + onDownloadXLSX(); + } + }, + items: [ + { + key: 'csv', + label: t('Export to CSV'), + icon: , + }, + { + key: 'xlsx', + label: t('Export to Excel'), + icon: , + }, + ], + }} + > + + + * { + line-height: 0; + } + `} + /> + + + + ); +}; + +export default DownloadDropdown; diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx index 747c17d80bf..6922669e479 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.tsx @@ -33,6 +33,7 @@ import { ensureIsArray, JsonObject, QueryFormData, + SupersetClient, } from '@superset-ui/core'; import { css, useTheme } from '@apache-superset/core/theme'; import { GenericDataType } from '@apache-superset/core/common'; @@ -47,6 +48,10 @@ import Table, { TableSize, } from '@superset-ui/core/components/Table'; import { RootState } from 'src/dashboard/types'; +import { usePermissions } from 'src/hooks/usePermissions'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import { ensureAppRoot } from 'src/utils/pathUtils'; +import { safeStringify } from 'src/utils/safeStringify'; import HeaderWithRadioGroup from '@superset-ui/core/components/Table/header-renderers/HeaderWithRadioGroup'; import { useDatasetMetadataBar } from 'src/features/datasets/metadataBar/useDatasetMetadataBar'; import { Dataset } from '../types'; @@ -108,6 +113,13 @@ export default function DrillDetailPane({ state.common.conf.SAMPLES_ROW_LIMIT, ); + const ROW_LIMIT = useSelector( + (state: { common: { conf: JsonObject } }) => state.common.conf.ROW_LIMIT, + ); + + const { canDownload } = usePermissions(); + const { addDangerToast } = useToasts(); + // Extract datasource ID/type from string ID const [datasourceId, datasourceType] = useMemo( () => formData.datasource.split('__'), @@ -207,6 +219,64 @@ export default function DrillDetailPane({ setPageIndex(0); }, []); + const handleDownload = useCallback( + (exportType: 'csv' | 'xlsx') => { + const drillPayload = getDrillPayload(formData, filters); + if (!drillPayload) { + addDangerToast(t('Unable to generate download payload')); + return; + } + const payload: JsonObject = { + datasource: { + id: parseInt(datasourceId, 10), + type: datasourceType, + }, + queries: [ + { + ...drillPayload, + columns: [], + metrics: [], + orderby: [], + row_limit: ROW_LIMIT, + row_offset: 0, + }, + ], + result_type: 'drill_detail', + result_format: exportType, + force: false, + }; + if (dashboardId) { + payload.form_data = { dashboardId }; + } + SupersetClient.postForm(ensureAppRoot('/api/v1/chart/data'), { + form_data: safeStringify(payload), + }).catch(error => { + addDangerToast( + t('Failed to generate download: %s', error.message || error), + ); + }); + }, + [ + formData, + filters, + datasourceId, + datasourceType, + ROW_LIMIT, + dashboardId, + addDangerToast, + ], + ); + + const handleDownloadCSV = useCallback( + () => handleDownload('csv'), + [handleDownload], + ); + + const handleDownloadXLSX = useCallback( + () => handleDownload('xlsx'), + [handleDownload], + ); + // Clear cache and reset page index if filters change useEffect(() => { setResponseError(''); @@ -338,6 +408,11 @@ export default function DrillDetailPane({ totalCount={resultsPage?.total} loading={isLoading} onReload={handleReload} + canDownload={canDownload} + onDownloadCSV={handleDownloadCSV} + onDownloadXLSX={handleDownloadXLSX} + data={data} + columnNames={resultsPage?.colNames} /> )} {tableContent} diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.stories.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.stories.tsx index 2af6c354f4a..858d28cf14f 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.stories.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.stories.tsx @@ -33,4 +33,9 @@ InteractiveTableControls.args = { { op: '>', col: 'tz_offset', val: 200 }, { op: '==', col: 'platform', val: 'GB' }, ], + canDownload: true, + onDownloadCSV: () => {}, + onDownloadXLSX: () => {}, + onReload: () => {}, + loading: false, }; diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.test.tsx index 91ac078ed8e..c9c09002eb3 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.test.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.test.tsx @@ -21,6 +21,8 @@ import TableControls from './DrillDetailTableControls'; const setFilters = jest.fn(); const onReload = jest.fn(); +const onDownloadCSV = jest.fn(); +const onDownloadXLSX = jest.fn(); const setup = (overrides: Record = {}) => { const props = { filters: [], @@ -28,9 +30,14 @@ const setup = (overrides: Record = {}) => { onReload, loading: false, totalCount: 0, + canDownload: true, + onDownloadCSV, + onDownloadXLSX, + data: [], + columnNames: [], ...overrides, }; - return render(); + return render(, { useRedux: true }); }; test('should render', () => { const { container } = setup(); diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.tsx index f1c7e23af39..8fbf06abc7f 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailTableControls.tsx @@ -27,6 +27,9 @@ import { import { css, useTheme } from '@apache-superset/core/theme'; import RowCountLabel from 'src/components/RowCountLabel'; import { Icons } from '@superset-ui/core/components/Icons'; +import { Tooltip } from '@superset-ui/core/components'; +import { CopyToClipboardButton } from 'src/explore/components/DataTableControl'; +import DownloadDropdown from './DownloadDropdown'; export type TableControlsProps = { filters: BinaryQueryObjectFilterClause[]; @@ -34,6 +37,11 @@ export type TableControlsProps = { totalCount?: number; loading: boolean; onReload: () => void; + canDownload: boolean; + onDownloadCSV: () => void; + onDownloadXLSX: () => void; + data?: Record[]; + columnNames?: string[]; }; export default function TableControls({ @@ -42,6 +50,11 @@ export default function TableControls({ totalCount, loading, onReload, + canDownload, + onDownloadCSV, + onDownloadXLSX, + data, + columnNames, }: TableControlsProps) { const theme = useTheme(); const filterMap: Record = useMemo( @@ -118,16 +131,28 @@ export default function TableControls({ display: flex; align-items: center; height: min-content; + gap: ${theme.sizeUnit * 3}px; `} > - + {canDownload && ( + + )} + {canDownload && ( + + )} + + +
); diff --git a/superset-frontend/src/explore/components/DataTableControl/index.tsx b/superset-frontend/src/explore/components/DataTableControl/index.tsx index 3a2698cd9b3..11599cf2c80 100644 --- a/superset-frontend/src/explore/components/DataTableControl/index.tsx +++ b/superset-frontend/src/explore/components/DataTableControl/index.tsx @@ -63,33 +63,40 @@ export const CopyToClipboardButton = ({ data?: TabularDataRow[]; columns?: string[]; disabled?: boolean; -}) => ( - * { - line-height: 0; - } - `} - /> - } - /> -); +}) => { + const theme = useTheme(); + return ( + + * { + line-height: 0; + } + `} + /> + + } + /> + ); +}; export const FilterInput = ({ onChangeHandler, diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx index a2f257af96c..03a9f717c70 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx @@ -16,13 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { styled, css } from '@apache-superset/core/theme'; -import { GenericDataType } from '@apache-superset/core/common'; +import { styled, css, useTheme } from '@apache-superset/core/theme'; import { t } from '@apache-superset/core/translation'; +import { GenericDataType } from '@apache-superset/core/common'; import { useMemo } from 'react'; import { zip } from 'lodash'; import { Tooltip } from '@superset-ui/core/components'; import { Select } from 'antd'; +import { Icons } from '@superset-ui/core/components/Icons'; import { CopyToClipboardButton, FilterInput, @@ -31,6 +32,7 @@ import { applyFormattingToTabularData } from 'src/utils/common'; import { getTimeColumns } from 'src/explore/components/DataTableControl/utils'; import RowCountLabel from 'src/components/RowCountLabel'; import { usePermissions } from 'src/hooks/usePermissions'; +import DownloadDropdown from 'src/components/Chart/DrillDetail/DownloadDropdown'; import { TableControlsProps } from '../types'; export const ROW_LIMIT_OPTIONS = [ @@ -63,10 +65,15 @@ export const TableControls = ({ columnTypes, rowcount, isLoading, + canDownload, rowLimit, rowLimitOptions, onRowLimitChange, + onDownloadCSV, + onDownloadXLSX, + onReload, }: TableControlsProps) => { + const theme = useTheme(); const originalTimeColumns = getTimeColumns(datasourceId); const formattedTimeColumns = zip( columnNames, @@ -109,6 +116,12 @@ export const TableControls = ({ {(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && ( )} + {canDownload && onDownloadCSV && onDownloadXLSX && ( + + )} {copyEnabled ? ( ) : ( @@ -122,6 +135,17 @@ export const TableControls = ({ )} + {onReload && ( + + + + )} ); diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/SingleQueryResultPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/SingleQueryResultPane.tsx index 2b27f515a2c..8cd279f4164 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/components/SingleQueryResultPane.tsx +++ b/superset-frontend/src/explore/components/DataTablesPane/components/SingleQueryResultPane.tsx @@ -57,6 +57,9 @@ export const SingleQueryResultPane = ({ rowLimit, rowLimitOptions, onRowLimitChange, + onDownloadCSV, + onDownloadXLSX, + onReload, }: SingleQueryResultPaneProp) => { const [filterText, setFilterText] = useState(''); const { gridHeight, measuredRef } = useGridHeight(); @@ -83,6 +86,9 @@ export const SingleQueryResultPane = ({ rowLimit={rowLimit} rowLimitOptions={rowLimitOptions} onRowLimitChange={onRowLimitChange} + onDownloadCSV={onDownloadCSV} + onDownloadXLSX={onDownloadXLSX} + onReload={onReload} /> diff --git a/superset-frontend/src/explore/components/DataTablesPane/types.ts b/superset-frontend/src/explore/components/DataTablesPane/types.ts index b01523f4d62..6e6a2c4b061 100644 --- a/superset-frontend/src/explore/components/DataTablesPane/types.ts +++ b/superset-frontend/src/explore/components/DataTablesPane/types.ts @@ -76,6 +76,9 @@ export interface TableControlsProps { rowLimit?: number; rowLimitOptions?: { value: number; label: string }[]; onRowLimitChange?: (limit: number) => void; + onDownloadCSV?: () => void; + onDownloadXLSX?: () => void; + onReload?: () => void; } export interface QueryResultInterface { @@ -95,4 +98,7 @@ export interface SingleQueryResultPaneProp extends QueryResultInterface { rowLimit?: number; rowLimitOptions?: { value: number; label: string }[]; onRowLimitChange?: (limit: number) => void; + onDownloadCSV?: () => void; + onDownloadXLSX?: () => void; + onReload?: () => void; } diff --git a/superset/views/base.py b/superset/views/base.py index 4351ef01743..3f8b56e74bd 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -101,6 +101,7 @@ FRONTEND_CONF_KEYS = ( "COLUMNAR_EXTENSIONS", "ALLOWED_EXTENSIONS", "SAMPLES_ROW_LIMIT", + "ROW_LIMIT", "DEFAULT_TIME_FILTER", "HTML_SANITIZATION", "HTML_SANITIZATION_SCHEMA_EXTENSIONS",