mirror of
https://github.com/apache/superset.git
synced 2026-08-12 11:11:01 +00:00
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:
co-authored by
Claude Opus 4.8
parent
cea084898c
commit
849f284af1
+61
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user