mirror of
https://github.com/apache/superset.git
synced 2026-06-16 13:09:20 +00:00
Compare commits
16 Commits
bump-setup
...
csp-frame
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7186f92d38 | ||
|
|
513fc30bb5 | ||
|
|
4a94a4c387 | ||
|
|
8a6461816a | ||
|
|
0b251d80fa | ||
|
|
efe10ecebe | ||
|
|
6c11c49524 | ||
|
|
9afd7609a5 | ||
|
|
18a325a4eb | ||
|
|
c1f9541640 | ||
|
|
22afa0448f | ||
|
|
da61031603 | ||
|
|
252f306a30 | ||
|
|
c9003b6697 | ||
|
|
4d5c78953c | ||
|
|
001cccac54 |
@@ -25,8 +25,19 @@ import {
|
|||||||
import { VizType } from '@superset-ui/core';
|
import { VizType } from '@superset-ui/core';
|
||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import handleResourceExport from 'src/utils/export';
|
||||||
import ChartTable from './ChartTable';
|
import ChartTable from './ChartTable';
|
||||||
|
|
||||||
|
// Mock the export module
|
||||||
|
jest.mock('src/utils/export', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockExport = handleResourceExport as jest.MockedFunction<
|
||||||
|
typeof handleResourceExport
|
||||||
|
>;
|
||||||
|
|
||||||
const chartsEndpoint = 'glob:*/api/v1/chart/?*';
|
const chartsEndpoint = 'glob:*/api/v1/chart/?*';
|
||||||
const chartsInfoEndpoint = 'glob:*/api/v1/chart/_info*';
|
const chartsInfoEndpoint = 'glob:*/api/v1/chart/_info*';
|
||||||
const chartFavoriteStatusEndpoint = 'glob:*/api/v1/chart/favorite_status*';
|
const chartFavoriteStatusEndpoint = 'glob:*/api/v1/chart/favorite_status*';
|
||||||
@@ -47,7 +58,7 @@ fetchMock.get(chartsEndpoint, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fetchMock.get(chartsInfoEndpoint, {
|
fetchMock.get(chartsInfoEndpoint, {
|
||||||
permissions: ['can_add', 'can_edit', 'can_delete'],
|
permissions: ['can_add', 'can_edit', 'can_delete', 'can_export'],
|
||||||
});
|
});
|
||||||
|
|
||||||
fetchMock.get(chartFavoriteStatusEndpoint, {
|
fetchMock.get(chartFavoriteStatusEndpoint, {
|
||||||
@@ -115,3 +126,53 @@ test('renders mine tab on click', async () => {
|
|||||||
expect(screen.getAllByText(/cool chart/i)).toHaveLength(3);
|
expect(screen.getAllByText(/cool chart/i)).toHaveLength(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('handles chart export with correct ID and shows spinner', async () => {
|
||||||
|
// Mock export to take some time before calling the done callback
|
||||||
|
mockExport.mockImplementation(
|
||||||
|
(resource: string, ids: number[], done: () => void) =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
setTimeout(() => {
|
||||||
|
done();
|
||||||
|
resolve();
|
||||||
|
}, 100);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await renderChartTable(mineTabProps);
|
||||||
|
|
||||||
|
// Click Mine tab to see charts
|
||||||
|
userEvent.click(screen.getByText(/mine/i));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByText(/cool chart/i)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find and click the more options button for the first chart
|
||||||
|
const moreButtons = screen.getAllByRole('img', { name: /more/i });
|
||||||
|
await userEvent.click(moreButtons[0]);
|
||||||
|
|
||||||
|
// Wait for dropdown menu
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Export')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportOption = screen.getByText('Export');
|
||||||
|
await userEvent.click(exportOption);
|
||||||
|
|
||||||
|
// Verify spinner appears during export
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the export was called with correct parameters
|
||||||
|
expect(mockExport).toHaveBeenCalledWith('chart', [0], expect.any(Function));
|
||||||
|
|
||||||
|
// Wait for export to complete and spinner to disappear
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
{ timeout: 3000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -132,12 +132,17 @@ function ChartTable({
|
|||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
const handleBulkChartExport = (chartsToExport: Chart[]) => {
|
const handleBulkChartExport = async (chartsToExport: Chart[]) => {
|
||||||
const ids = chartsToExport.map(({ id }) => id);
|
const ids = chartsToExport.map(({ id }) => id);
|
||||||
handleResourceExport('chart', ids, () => {
|
|
||||||
setPreparingExport(false);
|
|
||||||
});
|
|
||||||
setPreparingExport(true);
|
setPreparingExport(true);
|
||||||
|
try {
|
||||||
|
await handleResourceExport('chart', ids, () => {
|
||||||
|
setPreparingExport(false);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setPreparingExport(false);
|
||||||
|
addDangerToast(t('There was an issue exporting the selected charts'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuTabs = [
|
const menuTabs = [
|
||||||
|
|||||||
@@ -28,8 +28,19 @@ import { Router } from 'react-router-dom';
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
import * as hooks from 'src/views/CRUD/hooks';
|
import * as hooks from 'src/views/CRUD/hooks';
|
||||||
|
import handleResourceExport from 'src/utils/export';
|
||||||
import DashboardTable from './DashboardTable';
|
import DashboardTable from './DashboardTable';
|
||||||
|
|
||||||
|
// Mock the export module
|
||||||
|
jest.mock('src/utils/export', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockExport = handleResourceExport as jest.MockedFunction<
|
||||||
|
typeof handleResourceExport
|
||||||
|
>;
|
||||||
|
|
||||||
jest.mock('src/views/CRUD/utils', () => ({
|
jest.mock('src/views/CRUD/utils', () => ({
|
||||||
...jest.requireActual('src/views/CRUD/utils'),
|
...jest.requireActual('src/views/CRUD/utils'),
|
||||||
handleDashboardDelete: jest
|
handleDashboardDelete: jest
|
||||||
@@ -254,12 +265,38 @@ describe('DashboardTable', () => {
|
|||||||
expect(otherTab).toHaveClass('active');
|
expect(otherTab).toHaveClass('active');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles bulk dashboard export', async () => {
|
test('handles bulk dashboard export with correct ID and shows spinner', async () => {
|
||||||
|
// Mock export to take some time before calling the done callback
|
||||||
|
mockExport.mockImplementation(
|
||||||
|
(resource: string, ids: number[], done: () => void) =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
setTimeout(() => {
|
||||||
|
done();
|
||||||
|
resolve();
|
||||||
|
}, 100);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const props = {
|
const props = {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
mine: mockDashboards,
|
mine: mockDashboards,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
jest.spyOn(hooks, 'useListViewResource').mockImplementation(() => ({
|
||||||
|
state: {
|
||||||
|
loading: false,
|
||||||
|
resourceCollection: mockDashboards,
|
||||||
|
resourceCount: mockDashboards.length,
|
||||||
|
bulkSelectEnabled: false,
|
||||||
|
lastFetched: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
setResourceCollection: jest.fn(),
|
||||||
|
hasPerm: jest.fn().mockReturnValue(true),
|
||||||
|
refreshData: jest.fn(),
|
||||||
|
fetchData: jest.fn(),
|
||||||
|
toggleBulkSelect: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<Router history={history}>
|
<Router history={history}>
|
||||||
<DashboardTable {...props} />
|
<DashboardTable {...props} />
|
||||||
@@ -280,7 +317,25 @@ describe('DashboardTable', () => {
|
|||||||
const exportOption = screen.getByText('Export');
|
const exportOption = screen.getByText('Export');
|
||||||
await userEvent.click(exportOption);
|
await userEvent.click(exportOption);
|
||||||
|
|
||||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
// Verify spinner shows up during export
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the export was called with correct parameters
|
||||||
|
expect(mockExport).toHaveBeenCalledWith(
|
||||||
|
'dashboard',
|
||||||
|
[1],
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for export to complete and spinner to disappear
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
{ timeout: 3000 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles dashboard deletion confirmation', async () => {
|
test('handles dashboard deletion confirmation', async () => {
|
||||||
|
|||||||
@@ -118,12 +118,17 @@ function DashboardTable({
|
|||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
const handleBulkDashboardExport = (dashboardsToExport: Dashboard[]) => {
|
const handleBulkDashboardExport = async (dashboardsToExport: Dashboard[]) => {
|
||||||
const ids = dashboardsToExport.map(({ id }) => id);
|
const ids = dashboardsToExport.map(({ id }) => id);
|
||||||
handleResourceExport('dashboard', ids, () => {
|
|
||||||
setPreparingExport(false);
|
|
||||||
});
|
|
||||||
setPreparingExport(true);
|
setPreparingExport(true);
|
||||||
|
try {
|
||||||
|
await handleResourceExport('dashboard', ids, () => {
|
||||||
|
setPreparingExport(false);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setPreparingExport(false);
|
||||||
|
addDangerToast(t('There was an issue exporting the selected dashboards'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDashboardEdit = (edits: Dashboard) =>
|
const handleDashboardEdit = (edits: Dashboard) =>
|
||||||
|
|||||||
@@ -241,12 +241,17 @@ function ChartList(props: ChartListProps) {
|
|||||||
const canDelete = hasPerm('can_write');
|
const canDelete = hasPerm('can_write');
|
||||||
const canExport = hasPerm('can_export');
|
const canExport = hasPerm('can_export');
|
||||||
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
|
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
|
||||||
const handleBulkChartExport = (chartsToExport: Chart[]) => {
|
const handleBulkChartExport = async (chartsToExport: Chart[]) => {
|
||||||
const ids = chartsToExport.map(({ id }) => id);
|
const ids = chartsToExport.map(({ id }) => id);
|
||||||
handleResourceExport('chart', ids, () => {
|
|
||||||
setPreparingExport(false);
|
|
||||||
});
|
|
||||||
setPreparingExport(true);
|
setPreparingExport(true);
|
||||||
|
try {
|
||||||
|
await handleResourceExport('chart', ids, () => {
|
||||||
|
setPreparingExport(false);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setPreparingExport(false);
|
||||||
|
addDangerToast(t('There was an issue exporting the selected charts'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleBulkChartDelete(chartsToDelete: Chart[]) {
|
function handleBulkChartDelete(chartsToDelete: Chart[]) {
|
||||||
|
|||||||
@@ -274,12 +274,17 @@ function DashboardList(props: DashboardListProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBulkDashboardExport = (dashboardsToExport: Dashboard[]) => {
|
const handleBulkDashboardExport = async (dashboardsToExport: Dashboard[]) => {
|
||||||
const ids = dashboardsToExport.map(({ id }) => id);
|
const ids = dashboardsToExport.map(({ id }) => id);
|
||||||
handleResourceExport('dashboard', ids, () => {
|
|
||||||
setPreparingExport(false);
|
|
||||||
});
|
|
||||||
setPreparingExport(true);
|
setPreparingExport(true);
|
||||||
|
try {
|
||||||
|
await handleResourceExport('dashboard', ids, () => {
|
||||||
|
setPreparingExport(false);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setPreparingExport(false);
|
||||||
|
addDangerToast(t('There was an issue exporting the selected dashboards'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleBulkDashboardDelete(dashboardsToDelete: Dashboard[]) {
|
function handleBulkDashboardDelete(dashboardsToDelete: Dashboard[]) {
|
||||||
|
|||||||
@@ -331,15 +331,20 @@ function DatabaseList({
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDatabaseExport(database: DatabaseObject) {
|
async function handleDatabaseExport(database: DatabaseObject) {
|
||||||
if (database.id === undefined) {
|
if (database.id === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleResourceExport('database', [database.id], () => {
|
|
||||||
setPreparingExport(false);
|
|
||||||
});
|
|
||||||
setPreparingExport(true);
|
setPreparingExport(true);
|
||||||
|
try {
|
||||||
|
await handleResourceExport('database', [database.id], () => {
|
||||||
|
setPreparingExport(false);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setPreparingExport(false);
|
||||||
|
addDangerToast(t('There was an issue exporting the database'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDatabasePermSync(database: DatabaseObject) {
|
function handleDatabasePermSync(database: DatabaseObject) {
|
||||||
|
|||||||
@@ -275,12 +275,17 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
|||||||
setDatasetCurrentlyDuplicating(dataset);
|
setDatasetCurrentlyDuplicating(dataset);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkDatasetExport = (datasetsToExport: Dataset[]) => {
|
const handleBulkDatasetExport = async (datasetsToExport: Dataset[]) => {
|
||||||
const ids = datasetsToExport.map(({ id }) => id);
|
const ids = datasetsToExport.map(({ id }) => id);
|
||||||
handleResourceExport('dataset', ids, () => {
|
|
||||||
setPreparingExport(false);
|
|
||||||
});
|
|
||||||
setPreparingExport(true);
|
setPreparingExport(true);
|
||||||
|
try {
|
||||||
|
await handleResourceExport('dataset', ids, () => {
|
||||||
|
setPreparingExport(false);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setPreparingExport(false);
|
||||||
|
addDangerToast(t('There was an issue exporting the selected datasets'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
|
|||||||
@@ -291,14 +291,19 @@ function SavedQueryList({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkSavedQueryExport = (
|
const handleBulkSavedQueryExport = async (
|
||||||
savedQueriesToExport: SavedQueryObject[],
|
savedQueriesToExport: SavedQueryObject[],
|
||||||
) => {
|
) => {
|
||||||
const ids = savedQueriesToExport.map(({ id }) => id);
|
const ids = savedQueriesToExport.map(({ id }) => id);
|
||||||
handleResourceExport('saved_query', ids, () => {
|
|
||||||
setPreparingExport(false);
|
|
||||||
});
|
|
||||||
setPreparingExport(true);
|
setPreparingExport(true);
|
||||||
|
try {
|
||||||
|
await handleResourceExport('saved_query', ids, () => {
|
||||||
|
setPreparingExport(false);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setPreparingExport(false);
|
||||||
|
addDangerToast(t('There was an issue exporting the selected queries'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkQueryDelete = (queriesToDelete: SavedQueryObject[]) => {
|
const handleBulkQueryDelete = (queriesToDelete: SavedQueryObject[]) => {
|
||||||
|
|||||||
@@ -221,14 +221,19 @@ function ThemesList({
|
|||||||
setAppliedThemeId(null);
|
setAppliedThemeId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBulkThemeExport = (themesToExport: ThemeObject[]) => {
|
const handleBulkThemeExport = async (themesToExport: ThemeObject[]) => {
|
||||||
const ids = themesToExport
|
const ids = themesToExport
|
||||||
.map(({ id }) => id)
|
.map(({ id }) => id)
|
||||||
.filter((id): id is number => id !== undefined);
|
.filter((id): id is number => id !== undefined);
|
||||||
handleResourceExport('theme', ids, () => {
|
|
||||||
setPreparingExport(false);
|
|
||||||
});
|
|
||||||
setPreparingExport(true);
|
setPreparingExport(true);
|
||||||
|
try {
|
||||||
|
await handleResourceExport('theme', ids, () => {
|
||||||
|
setPreparingExport(false);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setPreparingExport(false);
|
||||||
|
addDangerToast(t('There was an issue exporting the selected themes'));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openThemeImportModal = () => {
|
const openThemeImportModal = () => {
|
||||||
|
|||||||
398
superset-frontend/src/utils/export.test.ts
Normal file
398
superset-frontend/src/utils/export.test.ts
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
/**
|
||||||
|
* 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 { SupersetClient, logging } from '@superset-ui/core';
|
||||||
|
import contentDisposition from 'content-disposition';
|
||||||
|
import handleResourceExport from './export';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('@superset-ui/core', () => ({
|
||||||
|
SupersetClient: {
|
||||||
|
get: jest.fn(),
|
||||||
|
},
|
||||||
|
logging: {
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('content-disposition');
|
||||||
|
|
||||||
|
jest.mock('./pathUtils', () => ({
|
||||||
|
ensureAppRoot: jest.fn((path: string) => path),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockBlob: Blob;
|
||||||
|
let mockResponse: Response;
|
||||||
|
let createElementSpy: jest.SpyInstance;
|
||||||
|
let createObjectURLSpy: jest.SpyInstance;
|
||||||
|
let revokeObjectURLSpy: jest.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset all mocks
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock Blob
|
||||||
|
mockBlob = new Blob(['test data'], { type: 'application/zip' });
|
||||||
|
|
||||||
|
// Mock Response with Headers
|
||||||
|
mockResponse = {
|
||||||
|
headers: new Headers({
|
||||||
|
'Content-Disposition': 'attachment; filename="dashboard_export.zip"',
|
||||||
|
}),
|
||||||
|
blob: jest.fn().mockResolvedValue(mockBlob),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
// Mock SupersetClient.get
|
||||||
|
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
// Mock DOM APIs
|
||||||
|
const mockAnchor = document.createElement('a');
|
||||||
|
mockAnchor.click = jest.fn();
|
||||||
|
createElementSpy = jest
|
||||||
|
.spyOn(document, 'createElement')
|
||||||
|
.mockReturnValue(mockAnchor);
|
||||||
|
jest.spyOn(document.body, 'appendChild').mockImplementation(() => mockAnchor);
|
||||||
|
jest.spyOn(document.body, 'removeChild').mockImplementation(() => mockAnchor);
|
||||||
|
|
||||||
|
// Mock URL.createObjectURL and revokeObjectURL
|
||||||
|
createObjectURLSpy = jest
|
||||||
|
.spyOn(window.URL, 'createObjectURL')
|
||||||
|
.mockReturnValue('blob:mock-url');
|
||||||
|
|
||||||
|
// Create revokeObjectURL if it doesn't exist
|
||||||
|
if (!window.URL.revokeObjectURL) {
|
||||||
|
window.URL.revokeObjectURL = jest.fn();
|
||||||
|
}
|
||||||
|
revokeObjectURLSpy = jest.spyOn(window.URL, 'revokeObjectURL');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
createElementSpy.mockRestore();
|
||||||
|
createObjectURLSpy.mockRestore();
|
||||||
|
if (revokeObjectURLSpy) {
|
||||||
|
revokeObjectURLSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exports resource with correct endpoint and headers', async () => {
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
await handleResourceExport('dashboard', [1, 2, 3], doneMock);
|
||||||
|
|
||||||
|
expect(SupersetClient.get).toHaveBeenCalledWith({
|
||||||
|
endpoint: '/api/v1/dashboard/export/?q=!(1,2,3)',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/zip, application/x-zip-compressed, text/plain',
|
||||||
|
},
|
||||||
|
parseMethod: 'raw',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates blob and triggers download', async () => {
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
await handleResourceExport('dashboard', [1], doneMock);
|
||||||
|
|
||||||
|
// Check that blob was created
|
||||||
|
expect(mockResponse.blob).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Check that object URL was created
|
||||||
|
expect(window.URL.createObjectURL).toHaveBeenCalledWith(mockBlob);
|
||||||
|
|
||||||
|
// Check that anchor element was created and configured
|
||||||
|
expect(document.createElement).toHaveBeenCalledWith('a');
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
expect(anchor.href).toBe('blob:mock-url');
|
||||||
|
expect(anchor.download).toBe('dashboard_export.zip');
|
||||||
|
// eslint-disable-next-line jest-dom/prefer-to-have-style -- toHaveStyle not available without React Testing Library
|
||||||
|
expect(anchor.style.display).toBe('none');
|
||||||
|
|
||||||
|
// Check that click was triggered
|
||||||
|
expect(anchor.click).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Check cleanup
|
||||||
|
expect(document.body.removeChild).toHaveBeenCalled();
|
||||||
|
expect(window.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls done callback on success', async () => {
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
await handleResourceExport('dashboard', [1], doneMock);
|
||||||
|
|
||||||
|
expect(doneMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses default filename when Content-Disposition is missing', async () => {
|
||||||
|
mockResponse = {
|
||||||
|
headers: new Headers(),
|
||||||
|
blob: jest.fn().mockResolvedValue(mockBlob),
|
||||||
|
} as unknown as Response;
|
||||||
|
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
await handleResourceExport('chart', [42], doneMock);
|
||||||
|
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
expect(anchor.download).toBe('chart_export.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles Content-Disposition parsing errors gracefully', async () => {
|
||||||
|
(contentDisposition.parse as jest.Mock).mockImplementationOnce(() => {
|
||||||
|
throw new Error('Invalid header');
|
||||||
|
});
|
||||||
|
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
await handleResourceExport('dashboard', [1], doneMock);
|
||||||
|
|
||||||
|
// Should fall back to default filename
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
expect(anchor.download).toBe('dashboard_export.zip');
|
||||||
|
expect(doneMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles API errors and calls done callback', async () => {
|
||||||
|
const apiError = new Error('API Error');
|
||||||
|
(SupersetClient.get as jest.Mock).mockRejectedValue(apiError);
|
||||||
|
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handleResourceExport('dashboard', [1], doneMock),
|
||||||
|
).rejects.toThrow('API Error');
|
||||||
|
|
||||||
|
expect(doneMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles blob conversion errors', async () => {
|
||||||
|
const blobError = new Error('Blob conversion failed');
|
||||||
|
mockResponse.blob = jest.fn().mockRejectedValue(blobError);
|
||||||
|
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handleResourceExport('dashboard', [1], doneMock),
|
||||||
|
).rejects.toThrow('Blob conversion failed');
|
||||||
|
|
||||||
|
expect(doneMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exports multiple resources with correct IDs', async () => {
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
await handleResourceExport('dataset', [10, 20, 30, 40], doneMock);
|
||||||
|
|
||||||
|
expect(SupersetClient.get).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
endpoint: '/api/v1/dataset/export/?q=!(10,20,30,40)',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parses filename from Content-Disposition with quotes', async () => {
|
||||||
|
(contentDisposition.parse as jest.Mock).mockReturnValueOnce({
|
||||||
|
type: 'attachment',
|
||||||
|
parameters: { filename: 'my_custom_export.zip' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
await handleResourceExport('dashboard', [1], doneMock);
|
||||||
|
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
expect(anchor.download).toBe('my_custom_export.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warns when export exceeds maximum blob size', async () => {
|
||||||
|
const largeFileSize = 150 * 1024 * 1024; // 150MB
|
||||||
|
|
||||||
|
mockResponse = {
|
||||||
|
headers: new Headers({
|
||||||
|
'Content-Length': largeFileSize.toString(),
|
||||||
|
'Content-Disposition': 'attachment; filename="large_export.zip"',
|
||||||
|
}),
|
||||||
|
blob: jest.fn().mockResolvedValue(mockBlob),
|
||||||
|
} as unknown as Response;
|
||||||
|
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
await handleResourceExport('dashboard', [1], doneMock);
|
||||||
|
|
||||||
|
expect(logging.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('exceeds maximum blob size'),
|
||||||
|
);
|
||||||
|
expect(doneMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles various resource types', async () => {
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
|
||||||
|
await handleResourceExport('dashboard', [1], doneMock);
|
||||||
|
expect(SupersetClient.get).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
endpoint: '/api/v1/dashboard/export/?q=!(1)',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleResourceExport('chart', [1], doneMock);
|
||||||
|
expect(SupersetClient.get).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
endpoint: '/api/v1/chart/export/?q=!(1)',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleResourceExport('dataset', [1], doneMock);
|
||||||
|
expect(SupersetClient.get).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
endpoint: '/api/v1/dataset/export/?q=!(1)',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleResourceExport('database', [1], doneMock);
|
||||||
|
expect(SupersetClient.get).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
endpoint: '/api/v1/database/export/?q=!(1)',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleResourceExport('query', [1], doneMock);
|
||||||
|
expect(SupersetClient.get).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
endpoint: '/api/v1/query/export/?q=!(1)',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(doneMock).toHaveBeenCalledTimes(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles network errors and logs them', async () => {
|
||||||
|
const networkError = new Error('Network request failed');
|
||||||
|
(SupersetClient.get as jest.Mock).mockRejectedValue(networkError);
|
||||||
|
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handleResourceExport('dashboard', [1], doneMock),
|
||||||
|
).rejects.toThrow('Network request failed');
|
||||||
|
|
||||||
|
expect(logging.error).toHaveBeenCalledWith(
|
||||||
|
'Resource export failed:',
|
||||||
|
networkError,
|
||||||
|
);
|
||||||
|
expect(doneMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles 404 errors when resource not found', async () => {
|
||||||
|
const notFoundError = new Error('Not found');
|
||||||
|
(SupersetClient.get as jest.Mock).mockRejectedValue(notFoundError);
|
||||||
|
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handleResourceExport('dashboard', [999], doneMock),
|
||||||
|
).rejects.toThrow('Not found');
|
||||||
|
|
||||||
|
expect(doneMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty response from server', async () => {
|
||||||
|
const emptyBlob = new Blob([], { type: 'application/zip' });
|
||||||
|
mockResponse = {
|
||||||
|
headers: new Headers({
|
||||||
|
'Content-Disposition': 'attachment; filename="empty.zip"',
|
||||||
|
}),
|
||||||
|
blob: jest.fn().mockResolvedValue(emptyBlob),
|
||||||
|
} as unknown as Response;
|
||||||
|
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
await handleResourceExport('dashboard', [1], doneMock);
|
||||||
|
|
||||||
|
expect(window.URL.createObjectURL).toHaveBeenCalledWith(emptyBlob);
|
||||||
|
expect(doneMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cleans up blob URL even when download fails', async () => {
|
||||||
|
const mockAnchor = document.createElement('a');
|
||||||
|
mockAnchor.click = jest.fn().mockImplementation(() => {
|
||||||
|
throw new Error('Click failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
createElementSpy.mockRestore();
|
||||||
|
createElementSpy = jest
|
||||||
|
.spyOn(document, 'createElement')
|
||||||
|
.mockReturnValue(mockAnchor);
|
||||||
|
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handleResourceExport('dashboard', [1], doneMock),
|
||||||
|
).rejects.toThrow('Click failed');
|
||||||
|
|
||||||
|
// Verify cleanup still happens
|
||||||
|
expect(window.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
|
||||||
|
expect(doneMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles malformed Content-Disposition header', async () => {
|
||||||
|
mockResponse = {
|
||||||
|
headers: new Headers({
|
||||||
|
'Content-Disposition': 'not-a-valid-header',
|
||||||
|
}),
|
||||||
|
blob: jest.fn().mockResolvedValue(mockBlob),
|
||||||
|
} as unknown as Response;
|
||||||
|
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
(contentDisposition.parse as jest.Mock).mockImplementationOnce(() => {
|
||||||
|
throw new Error('Parse error');
|
||||||
|
});
|
||||||
|
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
await handleResourceExport('dataset', [5], doneMock);
|
||||||
|
|
||||||
|
// Should fall back to default filename
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
expect(anchor.download).toBe('dataset_export.zip');
|
||||||
|
expect(logging.warn).toHaveBeenCalledWith(
|
||||||
|
'Failed to parse Content-Disposition header:',
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles missing headers object', async () => {
|
||||||
|
mockResponse = {
|
||||||
|
headers: new Headers(),
|
||||||
|
blob: jest.fn().mockResolvedValue(mockBlob),
|
||||||
|
} as unknown as Response;
|
||||||
|
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
await handleResourceExport('chart', [7], doneMock);
|
||||||
|
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
expect(anchor.download).toBe('chart_export.zip');
|
||||||
|
expect(doneMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles export with empty IDs array', async () => {
|
||||||
|
const doneMock = jest.fn();
|
||||||
|
await handleResourceExport('dashboard', [], doneMock);
|
||||||
|
|
||||||
|
expect(SupersetClient.get).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
endpoint: '/api/v1/dashboard/export/?q=!()',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -16,34 +16,84 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import parseCookie from 'src/utils/parseCookie';
|
import { SupersetClient, logging } from '@superset-ui/core';
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
import { nanoid } from 'nanoid';
|
import contentDisposition from 'content-disposition';
|
||||||
import { ensureAppRoot } from './pathUtils';
|
import { ensureAppRoot } from './pathUtils';
|
||||||
|
|
||||||
export default function handleResourceExport(
|
// Maximum blob size for in-memory downloads (100MB)
|
||||||
|
const MAX_BLOB_SIZE = 100 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a blob as a file using a temporary anchor element
|
||||||
|
* @param blob - The blob to download
|
||||||
|
* @param fileName - The filename to use for the download
|
||||||
|
*/
|
||||||
|
function downloadBlob(blob: Blob, fileName: string): void {
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.style.display = 'none';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} finally {
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handleResourceExport(
|
||||||
resource: string,
|
resource: string,
|
||||||
ids: number[],
|
ids: number[],
|
||||||
done: () => void,
|
done: () => void,
|
||||||
interval = 200,
|
): Promise<void> {
|
||||||
): void {
|
const endpoint = ensureAppRoot(
|
||||||
const token = nanoid();
|
`/api/v1/${resource}/export/?q=${rison.encode(ids)}`,
|
||||||
const url = ensureAppRoot(
|
|
||||||
`/api/v1/${resource}/export/?q=${rison.encode(ids)}&token=${token}`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// create new iframe for export
|
try {
|
||||||
const iframe = document.createElement('iframe');
|
// Use fetch with blob response instead of iframe to avoid CSP frame-src violations
|
||||||
iframe.style.display = 'none';
|
const response = await SupersetClient.get({
|
||||||
iframe.src = url;
|
endpoint,
|
||||||
document.body.appendChild(iframe);
|
headers: {
|
||||||
|
Accept: 'application/zip, application/x-zip-compressed, text/plain',
|
||||||
|
},
|
||||||
|
parseMethod: 'raw',
|
||||||
|
});
|
||||||
|
|
||||||
const timer = window.setInterval(() => {
|
// Check content length to prevent memory issues with large exports
|
||||||
const cookie: { [cookieId: string]: string } = parseCookie();
|
const contentLength = response.headers.get('Content-Length');
|
||||||
if (cookie[token] === 'done') {
|
if (contentLength && parseInt(contentLength, 10) > MAX_BLOB_SIZE) {
|
||||||
window.clearInterval(timer);
|
logging.warn(
|
||||||
document.body.removeChild(iframe);
|
`Export file size (${contentLength} bytes) exceeds maximum blob size (${MAX_BLOB_SIZE} bytes). Large exports may cause memory issues.`,
|
||||||
done();
|
);
|
||||||
}
|
}
|
||||||
}, interval);
|
|
||||||
|
// Parse filename from Content-Disposition header
|
||||||
|
const disposition = response.headers.get('Content-Disposition');
|
||||||
|
let fileName = `${resource}_export.zip`;
|
||||||
|
|
||||||
|
if (disposition) {
|
||||||
|
try {
|
||||||
|
const parsed = contentDisposition.parse(disposition);
|
||||||
|
if (parsed?.parameters?.filename) {
|
||||||
|
fileName = parsed.parameters.filename;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logging.warn('Failed to parse Content-Disposition header:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert response to blob and trigger download
|
||||||
|
const blob = await response.blob();
|
||||||
|
downloadBlob(blob, fileName);
|
||||||
|
|
||||||
|
done();
|
||||||
|
} catch (error) {
|
||||||
|
logging.error('Resource export failed:', error);
|
||||||
|
done();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user