feat(drill-detail): add CSV/XLSX download + reload to drill modals

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 <noreply@anthropic.com>
This commit is contained in:
alex-poor
2026-04-14 22:16:25 +12:00
parent 05fc5bb424
commit fa31ec8cb3
12 changed files with 336 additions and 44 deletions

View File

@@ -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<QueryData[]>();
const resultsTable = useResultsTableView(
chartDataResult,
formData.datasource,
canDownload,
);
const [currentFormData, setCurrentFormData] = useState(formData);
const [usedGroupbyColumns, setUsedGroupbyColumns] = useState<Column[]>(
[...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 ||

View File

@@ -35,6 +35,9 @@ export const useResultsTableView = (
chartDataResult: QueryData[] | undefined,
datasourceId: string,
canDownload: boolean,
onDownloadCSV?: () => void,
onDownloadXLSX?: () => void,
onReload?: () => void,
) => {
if (!isDefined(chartDataResult)) {
return <div />;
@@ -50,6 +53,9 @@ export const useResultsTableView = (
datasourceId={datasourceId}
isVisible
canDownload={canDownload}
onDownloadCSV={onDownloadCSV}
onDownloadXLSX={onDownloadXLSX}
onReload={onReload}
/>
</ResultContainer>
);
@@ -70,6 +76,9 @@ export const useResultsTableView = (
datasourceId={datasourceId}
isVisible
canDownload={canDownload}
onDownloadCSV={onDownloadCSV}
onDownloadXLSX={onDownloadXLSX}
onReload={onReload}
/>
</ResultContainer>
),

View File

@@ -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 (
<Dropdown
trigger={['click']}
menu={{
onClick: ({ key }) => {
if (key === 'csv') {
onDownloadCSV();
} else if (key === 'xlsx') {
onDownloadXLSX();
}
},
items: [
{
key: 'csv',
label: t('Export to CSV'),
icon: <Icons.FileOutlined />,
},
{
key: 'xlsx',
label: t('Export to Excel'),
icon: <Icons.FileOutlined />,
},
],
}}
>
<Tooltip title={t('Download')}>
<span
tabIndex={0}
role="button"
aria-label={t('Download')}
data-test="drill-detail-download-btn"
>
<Icons.DownloadOutlined
iconColor={theme.colorIcon}
iconSize="l"
css={css`
&.anticon > * {
line-height: 0;
}
`}
/>
</span>
</Tooltip>
</Dropdown>
);
};
export default DownloadDropdown;

View File

@@ -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}

View File

@@ -33,4 +33,9 @@ InteractiveTableControls.args = {
{ op: '>', col: 'tz_offset', val: 200 },
{ op: '==', col: 'platform', val: 'GB' },
],
canDownload: true,
onDownloadCSV: () => {},
onDownloadXLSX: () => {},
onReload: () => {},
loading: false,
};

View File

@@ -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<string, any> = {}) => {
const props = {
filters: [],
@@ -28,9 +30,14 @@ const setup = (overrides: Record<string, any> = {}) => {
onReload,
loading: false,
totalCount: 0,
canDownload: true,
onDownloadCSV,
onDownloadXLSX,
data: [],
columnNames: [],
...overrides,
};
return render(<TableControls {...props} />);
return render(<TableControls {...props} />, { useRedux: true });
};
test('should render', () => {
const { container } = setup();

View File

@@ -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<string, any>[];
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<string, BinaryQueryObjectFilterClause> = useMemo(
@@ -118,16 +131,28 @@ export default function TableControls({
display: flex;
align-items: center;
height: min-content;
gap: ${theme.sizeUnit * 3}px;
`}
>
<RowCountLabel loading={loading && !totalCount} rowcount={totalCount} />
<Icons.ReloadOutlined
iconColor={theme.colorIcon}
iconSize="l"
aria-label={t('Reload')}
role="button"
onClick={onReload}
/>
{canDownload && (
<DownloadDropdown
onDownloadCSV={onDownloadCSV}
onDownloadXLSX={onDownloadXLSX}
/>
)}
{canDownload && (
<CopyToClipboardButton data={data} columns={columnNames} />
)}
<Tooltip title={t('Reload')}>
<Icons.ReloadOutlined
iconColor={theme.colorIcon}
iconSize="l"
aria-label={t('Reload')}
role="button"
onClick={onReload}
/>
</Tooltip>
</div>
</div>
);

View File

@@ -63,33 +63,40 @@ export const CopyToClipboardButton = ({
data?: TabularDataRow[];
columns?: string[];
disabled?: boolean;
}) => (
<CopyToClipboard
text={
!disabled && data && columns
? prepareCopyToClipboardTabularData(data, columns)
: ''
}
disabled={disabled}
wrapped={false}
copyNode={
<Icons.CopyOutlined
iconSize="l"
aria-label={t('Copy')}
aria-disabled={disabled}
role="button"
tabIndex={disabled ? -1 : 0}
css={css`
opacity: ${disabled ? 0.3 : 1};
cursor: ${disabled ? 'not-allowed' : 'pointer'};
&.anticon > * {
line-height: 0;
}
`}
/>
}
/>
);
}) => {
const theme = useTheme();
return (
<CopyToClipboard
text={
!disabled && data && columns
? prepareCopyToClipboardTabularData(data, columns)
: ''
}
disabled={disabled}
wrapped={false}
copyNode={
<span
role="button"
aria-label={t('Copy')}
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
>
<Icons.CopyOutlined
iconColor={theme.colorIcon}
iconSize="l"
css={css`
opacity: ${disabled ? 0.3 : 1};
cursor: ${disabled ? 'not-allowed' : 'pointer'};
&.anticon > * {
line-height: 0;
}
`}
/>
</span>
}
/>
);
};
export const FilterInput = ({
onChangeHandler,

View File

@@ -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<string, GenericDataType>(
columnNames,
@@ -109,6 +116,12 @@ export const TableControls = ({
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
<RowCountLabel rowcount={rowcount} loading={isLoading} />
)}
{canDownload && onDownloadCSV && onDownloadXLSX && (
<DownloadDropdown
onDownloadCSV={onDownloadCSV}
onDownloadXLSX={onDownloadXLSX}
/>
)}
{copyEnabled ? (
<CopyToClipboardButton data={formattedData} columns={columnNames} />
) : (
@@ -122,6 +135,17 @@ export const TableControls = ({
</span>
</Tooltip>
)}
{onReload && (
<Tooltip title={t('Reload')}>
<Icons.ReloadOutlined
iconColor={theme.colorIcon}
iconSize="l"
aria-label={t('Reload')}
role="button"
onClick={onReload}
/>
</Tooltip>
)}
</div>
</TableControlsWrapper>
);

View File

@@ -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}
/>
<GridContainer>
<GridSizer ref={measuredRef}>

View File

@@ -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;
}

View File

@@ -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",