feat(dashboard): Export Data to Excel download menu item

Add an 'Export Data to Excel' item to the dashboard Download submenu (below the
divider, above Export YAML/Example), gated on the Dashboard can_export
permission (userCanExport). It POSTs the live native-filter data mask to the
export_xlsx endpoint and toasts: a pending message on 202, a 'not configured'
message on 501, and a generic error otherwise. Adds jest tests for visibility,
the POST payload, and the toast paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hugh A Miles II
2026-06-16 14:48:34 -07:00
co-authored by Claude Opus 4.8
parent cea084898c
commit 849f284af1
2 changed files with 106 additions and 1 deletions
@@ -26,6 +26,7 @@ import {
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import {
FeatureFlag,
getClientErrorObject,
isFeatureEnabled,
SupersetClient,
} from '@superset-ui/core';
@@ -46,12 +47,15 @@ jest.mock('src/components/MessageToasts/withToasts', () => ({
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn().mockReturnValue(false),
getClientErrorObject: jest.fn().mockResolvedValue({}),
SupersetClient: {
get: jest.fn(),
post: jest.fn(),
},
}));
const mockSupersetClient = SupersetClient as jest.Mocked<typeof SupersetClient>;
const mockGetClientErrorObject = getClientErrorObject as jest.Mock;
const createProps = () => ({
pdfMenuItemTitle: 'Export to PDF',
@@ -92,10 +96,67 @@ test('Should render all menu items', () => {
expect(screen.getByText('Download as Image')).toBeInTheDocument();
// Export options
expect(screen.getByText('Export Data to Excel')).toBeInTheDocument();
expect(screen.getByText('Export YAML')).toBeInTheDocument();
expect(screen.getByText('Export as Example')).toBeInTheDocument();
});
test('Export Data to Excel is hidden when userCanExport is false', () => {
render(<MenuWrapperWithProps userCanExport={false} />, { useRedux: true });
expect(screen.queryByText('Export Data to Excel')).not.toBeInTheDocument();
// YAML export is not gated and remains visible
expect(screen.getByText('Export YAML')).toBeInTheDocument();
});
test('Export Data to Excel posts active_data_mask and shows a pending toast', async () => {
mockSupersetClient.post.mockResolvedValue({} as never);
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export Data to Excel'));
await waitFor(() => {
expect(mockSupersetClient.post).toHaveBeenCalledWith({
endpoint: '/api/v1/dashboard/123/export_xlsx/',
jsonPayload: { active_data_mask: {} },
});
expect(mockAddSuccessToast).toHaveBeenCalledWith(
"Your export is being prepared. You'll receive an email when it's ready.",
);
});
});
test('Export Data to Excel shows a config error toast on 501', async () => {
mockSupersetClient.post.mockRejectedValue(new Error('not configured'));
mockGetClientErrorObject.mockResolvedValue({ status: 501 });
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export Data to Excel'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
'Excel export is not configured on this server.',
);
});
});
test('Export Data to Excel shows a generic error toast on other failures', async () => {
mockSupersetClient.post.mockRejectedValue(new Error('boom'));
mockGetClientErrorObject.mockResolvedValue({ status: 500 });
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export Data to Excel'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
'Sorry, something went wrong. Try again later.',
);
});
});
test('Export as Example calls SupersetClient.get with correct endpoint', async () => {
const mockBlob = new Blob(['test'], { type: 'application/zip' });
const mockResponse: Pick<Response, 'blob' | 'headers'> = {
@@ -17,17 +17,20 @@
* under the License.
*/
import { SyntheticEvent } from 'react';
import { useSelector } from 'react-redux';
import { logging } from '@apache-superset/core/utils';
import { t } from '@apache-superset/core/translation';
import {
FeatureFlag,
getClientErrorObject,
isFeatureEnabled,
SupersetClient,
} from '@superset-ui/core';
import { MenuItem } from '@superset-ui/core/components/Menu';
import { parse as parseContentDisposition } from 'content-disposition';
import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot';
import { MenuKeys } from 'src/dashboard/types';
import { NATIVE_FILTER_PREFIX } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import downloadAsPdf from 'src/utils/downloadAsPdf';
import downloadAsImage from 'src/utils/downloadAsImage';
import handleResourceExport from 'src/utils/export';
@@ -68,8 +71,19 @@ export const useDownloadMenuItems = (
} = props;
const { addDangerToast, addSuccessToast } = useToasts();
const dataMask = useSelector((state: RootState) => state.dataMask);
const SCREENSHOT_NODE_SELECTOR = '.dashboard';
const buildActiveDataMask = (): Record<string, { extraFormData: object }> =>
Object.entries(dataMask || {}).reduce<
Record<string, { extraFormData: object }>
>((acc, [id, mask]) => {
if (id.startsWith(NATIVE_FILTER_PREFIX)) {
acc[id] = { extraFormData: mask?.extraFormData ?? {} };
}
return acc;
}, {});
const isWebDriverScreenshotEnabled =
isFeatureEnabled(FeatureFlag.EnableDashboardScreenshotEndpoints) &&
isFeatureEnabled(FeatureFlag.EnableDashboardDownloadWebDriverScreenshot);
@@ -153,6 +167,27 @@ export const useDownloadMenuItems = (
}
};
const onExportXlsx = async () => {
try {
await SupersetClient.post({
endpoint: `/api/v1/dashboard/${dashboardId}/export_xlsx/`,
jsonPayload: { active_data_mask: buildActiveDataMask() },
});
addSuccessToast(
t(
"Your export is being prepared. You'll receive an email when it's ready.",
),
);
} catch (error) {
const { status } = await getClientErrorObject(error);
if (status === 501) {
addDangerToast(t('Excel export is not configured on this server.'));
} else {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
}
};
const imageDisabled = canExportImage === false;
const imageExportLabel = (text: string) =>
@@ -198,6 +233,15 @@ export const useDownloadMenuItems = (
];
const exportMenuItems: MenuItem[] = [
...(userCanExport
? [
{
key: 'export-xlsx',
label: t('Export Data to Excel'),
onClick: onExportXlsx,
},
]
: []),
{
key: 'export-yaml',
label: t('Export YAML'),