mirror of
https://github.com/apache/superset.git
synced 2026-07-19 21:25:38 +00:00
Compare commits
9 Commits
csp-frame
...
elizabeth/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0d59774ad | ||
|
|
69177a6d0d | ||
|
|
5a26910ee3 | ||
|
|
941907ed4e | ||
|
|
91fbc64327 | ||
|
|
79ff093b30 | ||
|
|
ff80d4f406 | ||
|
|
c846cd187c | ||
|
|
9a43a47e6a |
137
superset-frontend/package-lock.json
generated
137
superset-frontend/package-lock.json
generated
@@ -147,7 +147,7 @@
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.26.3",
|
||||
"@babel/plugin-transform-runtime": "^7.27.1",
|
||||
"@babel/plugin-transform-runtime": "^7.28.3",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
@@ -1346,22 +1346,47 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-define-polyfill-provider": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz",
|
||||
"integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==",
|
||||
"version": "0.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz",
|
||||
"integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-compilation-targets": "^7.22.6",
|
||||
"@babel/helper-plugin-utils": "^7.22.5",
|
||||
"debug": "^4.1.1",
|
||||
"@babel/helper-compilation-targets": "^7.27.2",
|
||||
"@babel/helper-plugin-utils": "^7.27.1",
|
||||
"debug": "^4.4.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"resolve": "^1.14.2"
|
||||
"resolve": "^1.22.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-define-polyfill-provider/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-define-polyfill-provider/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
@@ -2775,17 +2800,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-runtime": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.27.1.tgz",
|
||||
"integrity": "sha512-TqGF3desVsTcp3WrJGj4HfKokfCXCLcHpt4PJF0D8/iT6LPd9RS82Upw3KPeyr6B22Lfd3DO8MVrmp0oRkUDdw==",
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.28.3.tgz",
|
||||
"integrity": "sha512-Y6ab1kGqZ0u42Zv/4a7l0l72n9DKP/MKoKWaUSBylrhNZO2prYuqFOLbn5aW5SIFXwSH93yfjbgllL8lxuGKLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.27.1",
|
||||
"@babel/helper-plugin-utils": "^7.27.1",
|
||||
"babel-plugin-polyfill-corejs2": "^0.4.10",
|
||||
"babel-plugin-polyfill-corejs3": "^0.11.0",
|
||||
"babel-plugin-polyfill-regenerator": "^0.6.1",
|
||||
"babel-plugin-polyfill-corejs2": "^0.4.14",
|
||||
"babel-plugin-polyfill-corejs3": "^0.13.0",
|
||||
"babel-plugin-polyfill-regenerator": "^0.6.5",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2795,6 +2820,20 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz",
|
||||
"integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.5",
|
||||
"core-js-compat": "^3.43.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-runtime/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -20176,14 +20215,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||
"version": "0.4.12",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz",
|
||||
"integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==",
|
||||
"version": "0.4.14",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
|
||||
"integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.22.6",
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.3",
|
||||
"@babel/compat-data": "^7.27.7",
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.5",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -20215,13 +20254,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-polyfill-regenerator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz",
|
||||
"integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==",
|
||||
"version": "0.6.5",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz",
|
||||
"integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.3"
|
||||
"@babel/helper-define-polyfill-provider": "^0.6.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
|
||||
@@ -22745,13 +22784,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.40.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.40.0.tgz",
|
||||
"integrity": "sha512-0XEDpr5y5mijvw8Lbc6E5AkjrHfp7eEoPlu36SWeAbcL8fn1G1ANe8DBlo2XoNN89oVpxWwOjYIPVzR4ZvsKCQ==",
|
||||
"version": "3.45.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.1.tgz",
|
||||
"integrity": "sha512-tqTt5T4PzsMIZ430XGviK4vzYSoeNJ6CXODi6c/voxOT6IZqBht5/EKaSNnYiEjjRYxjVz7DQIsOsY0XNi8PIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.24.3"
|
||||
"browserslist": "^4.25.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -59263,7 +59302,7 @@
|
||||
"yosay": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
"jest": "^30.0.5",
|
||||
"yeoman-test": "^10.1.1"
|
||||
@@ -59956,6 +59995,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/generator-superset/node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@epic-web/invariant": "^1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "dist/bin/cross-env.js",
|
||||
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"packages/generator-superset/node_modules/dedent": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz",
|
||||
@@ -65885,7 +65942,7 @@
|
||||
"@luma.gl/shadertools": "^9.1.9",
|
||||
"@luma.gl/webgl": "^9.1.9",
|
||||
"@mapbox/geojson-extent": "^1.0.1",
|
||||
"@mapbox/tiny-sdf": "^2.0.6",
|
||||
"@mapbox/tiny-sdf": "^2.0.7",
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"@types/d3-array": "^2.0.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
@@ -65937,6 +65994,12 @@
|
||||
"@luma.gl/engine": "~9.1.9"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-preset-chart-deckgl/node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
|
||||
"integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"plugins/legacy-preset-chart-deckgl/node_modules/d3-array": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz",
|
||||
@@ -66020,7 +66083,7 @@
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"d3-tip": "^0.9.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"dompurify": "^3.2.7",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -66034,6 +66097,12 @@
|
||||
"react": "^17.0.2"
|
||||
}
|
||||
},
|
||||
"plugins/legacy-preset-chart-nvd3/node_modules/dayjs": {
|
||||
"version": "1.11.18",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz",
|
||||
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"plugins/legacy-preset-chart-nvd3/node_modules/dompurify": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
@@ -67385,7 +67454,7 @@
|
||||
"version": "0.20.3",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/types": "^7.28.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"jest": "^30.0.5"
|
||||
},
|
||||
@@ -67401,9 +67470,9 @@
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-pivot-table/node_modules/@babel/types": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz",
|
||||
"integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==",
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
|
||||
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
||||
"@babel/plugin-transform-export-namespace-from": "^7.27.1",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.26.3",
|
||||
"@babel/plugin-transform-runtime": "^7.27.1",
|
||||
"@babel/plugin-transform-runtime": "^7.28.3",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"yosay": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
"jest": "^30.0.5",
|
||||
"yeoman-test": "^10.1.1"
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"@luma.gl/engine": "^9.1.9",
|
||||
"@luma.gl/shadertools": "^9.1.9",
|
||||
"@luma.gl/webgl": "^9.1.9",
|
||||
"@mapbox/tiny-sdf": "^2.0.6",
|
||||
"@mapbox/tiny-sdf": "^2.0.7",
|
||||
"@mapbox/geojson-extent": "^1.0.1",
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"@types/d3-array": "^2.0.0",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"d3-tip": "^0.9.1",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.18",
|
||||
"nvd3-fork": "^2.0.5",
|
||||
"dompurify": "^3.2.7",
|
||||
"prop-types": "^15.8.1",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"react-dom": "^17.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.28.0",
|
||||
"@babel/types": "^7.28.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"jest": "^30.0.5"
|
||||
}
|
||||
|
||||
@@ -25,19 +25,8 @@ import {
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import handleResourceExport from 'src/utils/export';
|
||||
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 chartsInfoEndpoint = 'glob:*/api/v1/chart/_info*';
|
||||
const chartFavoriteStatusEndpoint = 'glob:*/api/v1/chart/favorite_status*';
|
||||
@@ -58,7 +47,7 @@ fetchMock.get(chartsEndpoint, {
|
||||
});
|
||||
|
||||
fetchMock.get(chartsInfoEndpoint, {
|
||||
permissions: ['can_add', 'can_edit', 'can_delete', 'can_export'],
|
||||
permissions: ['can_add', 'can_edit', 'can_delete'],
|
||||
});
|
||||
|
||||
fetchMock.get(chartFavoriteStatusEndpoint, {
|
||||
@@ -126,53 +115,3 @@ test('renders mine tab on click', async () => {
|
||||
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,17 +132,12 @@ function ChartTable({
|
||||
setLoaded(true);
|
||||
}, [activeTab]);
|
||||
|
||||
const handleBulkChartExport = async (chartsToExport: Chart[]) => {
|
||||
const handleBulkChartExport = (chartsToExport: Chart[]) => {
|
||||
const ids = chartsToExport.map(({ id }) => id);
|
||||
setPreparingExport(true);
|
||||
try {
|
||||
await handleResourceExport('chart', ids, () => {
|
||||
setPreparingExport(false);
|
||||
});
|
||||
} catch (error) {
|
||||
handleResourceExport('chart', ids, () => {
|
||||
setPreparingExport(false);
|
||||
addDangerToast(t('There was an issue exporting the selected charts'));
|
||||
}
|
||||
});
|
||||
setPreparingExport(true);
|
||||
};
|
||||
|
||||
const menuTabs = [
|
||||
|
||||
@@ -28,19 +28,8 @@ import { Router } from 'react-router-dom';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import * as hooks from 'src/views/CRUD/hooks';
|
||||
import handleResourceExport from 'src/utils/export';
|
||||
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.requireActual('src/views/CRUD/utils'),
|
||||
handleDashboardDelete: jest
|
||||
@@ -265,38 +254,12 @@ describe('DashboardTable', () => {
|
||||
expect(otherTab).toHaveClass('active');
|
||||
});
|
||||
|
||||
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);
|
||||
}),
|
||||
);
|
||||
|
||||
test('handles bulk dashboard export', async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
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(
|
||||
<Router history={history}>
|
||||
<DashboardTable {...props} />
|
||||
@@ -317,25 +280,7 @@ describe('DashboardTable', () => {
|
||||
const exportOption = screen.getByText('Export');
|
||||
await userEvent.click(exportOption);
|
||||
|
||||
// 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 },
|
||||
);
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles dashboard deletion confirmation', async () => {
|
||||
|
||||
@@ -118,17 +118,12 @@ function DashboardTable({
|
||||
setLoaded(true);
|
||||
}, [activeTab]);
|
||||
|
||||
const handleBulkDashboardExport = async (dashboardsToExport: Dashboard[]) => {
|
||||
const handleBulkDashboardExport = (dashboardsToExport: Dashboard[]) => {
|
||||
const ids = dashboardsToExport.map(({ id }) => id);
|
||||
setPreparingExport(true);
|
||||
try {
|
||||
await handleResourceExport('dashboard', ids, () => {
|
||||
setPreparingExport(false);
|
||||
});
|
||||
} catch (error) {
|
||||
handleResourceExport('dashboard', ids, () => {
|
||||
setPreparingExport(false);
|
||||
addDangerToast(t('There was an issue exporting the selected dashboards'));
|
||||
}
|
||||
});
|
||||
setPreparingExport(true);
|
||||
};
|
||||
|
||||
const handleDashboardEdit = (edits: Dashboard) =>
|
||||
|
||||
@@ -241,17 +241,12 @@ function ChartList(props: ChartListProps) {
|
||||
const canDelete = hasPerm('can_write');
|
||||
const canExport = hasPerm('can_export');
|
||||
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
|
||||
const handleBulkChartExport = async (chartsToExport: Chart[]) => {
|
||||
const handleBulkChartExport = (chartsToExport: Chart[]) => {
|
||||
const ids = chartsToExport.map(({ id }) => id);
|
||||
setPreparingExport(true);
|
||||
try {
|
||||
await handleResourceExport('chart', ids, () => {
|
||||
setPreparingExport(false);
|
||||
});
|
||||
} catch (error) {
|
||||
handleResourceExport('chart', ids, () => {
|
||||
setPreparingExport(false);
|
||||
addDangerToast(t('There was an issue exporting the selected charts'));
|
||||
}
|
||||
});
|
||||
setPreparingExport(true);
|
||||
};
|
||||
|
||||
function handleBulkChartDelete(chartsToDelete: Chart[]) {
|
||||
|
||||
@@ -274,17 +274,12 @@ function DashboardList(props: DashboardListProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const handleBulkDashboardExport = async (dashboardsToExport: Dashboard[]) => {
|
||||
const handleBulkDashboardExport = (dashboardsToExport: Dashboard[]) => {
|
||||
const ids = dashboardsToExport.map(({ id }) => id);
|
||||
setPreparingExport(true);
|
||||
try {
|
||||
await handleResourceExport('dashboard', ids, () => {
|
||||
setPreparingExport(false);
|
||||
});
|
||||
} catch (error) {
|
||||
handleResourceExport('dashboard', ids, () => {
|
||||
setPreparingExport(false);
|
||||
addDangerToast(t('There was an issue exporting the selected dashboards'));
|
||||
}
|
||||
});
|
||||
setPreparingExport(true);
|
||||
};
|
||||
|
||||
function handleBulkDashboardDelete(dashboardsToDelete: Dashboard[]) {
|
||||
|
||||
@@ -331,20 +331,15 @@ function DatabaseList({
|
||||
];
|
||||
}
|
||||
|
||||
async function handleDatabaseExport(database: DatabaseObject) {
|
||||
function handleDatabaseExport(database: DatabaseObject) {
|
||||
if (database.id === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreparingExport(true);
|
||||
try {
|
||||
await handleResourceExport('database', [database.id], () => {
|
||||
setPreparingExport(false);
|
||||
});
|
||||
} catch (error) {
|
||||
handleResourceExport('database', [database.id], () => {
|
||||
setPreparingExport(false);
|
||||
addDangerToast(t('There was an issue exporting the database'));
|
||||
}
|
||||
});
|
||||
setPreparingExport(true);
|
||||
}
|
||||
|
||||
function handleDatabasePermSync(database: DatabaseObject) {
|
||||
|
||||
@@ -275,17 +275,12 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
setDatasetCurrentlyDuplicating(dataset);
|
||||
};
|
||||
|
||||
const handleBulkDatasetExport = async (datasetsToExport: Dataset[]) => {
|
||||
const handleBulkDatasetExport = (datasetsToExport: Dataset[]) => {
|
||||
const ids = datasetsToExport.map(({ id }) => id);
|
||||
setPreparingExport(true);
|
||||
try {
|
||||
await handleResourceExport('dataset', ids, () => {
|
||||
setPreparingExport(false);
|
||||
});
|
||||
} catch (error) {
|
||||
handleResourceExport('dataset', ids, () => {
|
||||
setPreparingExport(false);
|
||||
addDangerToast(t('There was an issue exporting the selected datasets'));
|
||||
}
|
||||
});
|
||||
setPreparingExport(true);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
|
||||
@@ -291,19 +291,14 @@ function SavedQueryList({
|
||||
);
|
||||
};
|
||||
|
||||
const handleBulkSavedQueryExport = async (
|
||||
const handleBulkSavedQueryExport = (
|
||||
savedQueriesToExport: SavedQueryObject[],
|
||||
) => {
|
||||
const ids = savedQueriesToExport.map(({ id }) => id);
|
||||
setPreparingExport(true);
|
||||
try {
|
||||
await handleResourceExport('saved_query', ids, () => {
|
||||
setPreparingExport(false);
|
||||
});
|
||||
} catch (error) {
|
||||
handleResourceExport('saved_query', ids, () => {
|
||||
setPreparingExport(false);
|
||||
addDangerToast(t('There was an issue exporting the selected queries'));
|
||||
}
|
||||
});
|
||||
setPreparingExport(true);
|
||||
};
|
||||
|
||||
const handleBulkQueryDelete = (queriesToDelete: SavedQueryObject[]) => {
|
||||
|
||||
@@ -221,19 +221,14 @@ function ThemesList({
|
||||
setAppliedThemeId(null);
|
||||
}
|
||||
|
||||
const handleBulkThemeExport = async (themesToExport: ThemeObject[]) => {
|
||||
const handleBulkThemeExport = (themesToExport: ThemeObject[]) => {
|
||||
const ids = themesToExport
|
||||
.map(({ id }) => id)
|
||||
.filter((id): id is number => id !== undefined);
|
||||
setPreparingExport(true);
|
||||
try {
|
||||
await handleResourceExport('theme', ids, () => {
|
||||
setPreparingExport(false);
|
||||
});
|
||||
} catch (error) {
|
||||
handleResourceExport('theme', ids, () => {
|
||||
setPreparingExport(false);
|
||||
addDangerToast(t('There was an issue exporting the selected themes'));
|
||||
}
|
||||
});
|
||||
setPreparingExport(true);
|
||||
};
|
||||
|
||||
const openThemeImportModal = () => {
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
/**
|
||||
* 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,84 +16,34 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SupersetClient, logging } from '@superset-ui/core';
|
||||
import parseCookie from 'src/utils/parseCookie';
|
||||
import rison from 'rison';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { ensureAppRoot } from './pathUtils';
|
||||
|
||||
// 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(
|
||||
export default function handleResourceExport(
|
||||
resource: string,
|
||||
ids: number[],
|
||||
done: () => void,
|
||||
): Promise<void> {
|
||||
const endpoint = ensureAppRoot(
|
||||
`/api/v1/${resource}/export/?q=${rison.encode(ids)}`,
|
||||
interval = 200,
|
||||
): void {
|
||||
const token = nanoid();
|
||||
const url = ensureAppRoot(
|
||||
`/api/v1/${resource}/export/?q=${rison.encode(ids)}&token=${token}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Use fetch with blob response instead of iframe to avoid CSP frame-src violations
|
||||
const response = await SupersetClient.get({
|
||||
endpoint,
|
||||
headers: {
|
||||
Accept: 'application/zip, application/x-zip-compressed, text/plain',
|
||||
},
|
||||
parseMethod: 'raw',
|
||||
});
|
||||
// create new iframe for export
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.display = 'none';
|
||||
iframe.src = url;
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
// Check content length to prevent memory issues with large exports
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (contentLength && parseInt(contentLength, 10) > MAX_BLOB_SIZE) {
|
||||
logging.warn(
|
||||
`Export file size (${contentLength} bytes) exceeds maximum blob size (${MAX_BLOB_SIZE} bytes). Large exports may cause memory issues.`,
|
||||
);
|
||||
const timer = window.setInterval(() => {
|
||||
const cookie: { [cookieId: string]: string } = parseCookie();
|
||||
if (cookie[token] === 'done') {
|
||||
window.clearInterval(timer);
|
||||
document.body.removeChild(iframe);
|
||||
done();
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ from typing import Any
|
||||
|
||||
from celery import Task
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from celery.signals import task_failure
|
||||
from flask import current_app
|
||||
|
||||
from superset import is_feature_enabled
|
||||
@@ -41,8 +42,32 @@ from superset.utils.log import get_logger_from_status
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(name="reports.scheduler")
|
||||
def scheduler() -> None:
|
||||
@task_failure.connect
|
||||
def log_task_failure( # pylint: disable=unused-argument
|
||||
sender: Task | None = None,
|
||||
task_id: str | None = None,
|
||||
exception: Exception | None = None,
|
||||
args: tuple[Any, ...] | None = None,
|
||||
kwargs: dict[str, Any] | None = None,
|
||||
traceback: Any = None,
|
||||
einfo: Any = None,
|
||||
**kw: Any,
|
||||
) -> None:
|
||||
task_name = sender.name if sender else "Unknown"
|
||||
logger.exception("Celery task %s failed: %s", task_name, exception, exc_info=einfo)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
name="reports.scheduler",
|
||||
bind=True,
|
||||
autoretry_for=(Exception,),
|
||||
retry_kwargs={
|
||||
"max_retries": 3,
|
||||
"countdown": 60,
|
||||
}, # Retry up to 3 times, wait 60s between
|
||||
retry_backoff=True, # exponential backoff
|
||||
)
|
||||
def scheduler(self: Task) -> None: # pylint: disable=unused-argument
|
||||
"""
|
||||
Celery beat main scheduler for reports
|
||||
"""
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
# under the License.
|
||||
|
||||
from random import randint
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
@@ -25,7 +25,7 @@ from freezegun.api import FakeDatetime
|
||||
|
||||
from superset.extensions import db
|
||||
from superset.reports.models import ReportScheduleType
|
||||
from superset.tasks.scheduler import execute, scheduler
|
||||
from superset.tasks.scheduler import execute, log_task_failure, scheduler
|
||||
from tests.integration_tests.reports.utils import insert_report_schedule
|
||||
from tests.integration_tests.test_app import app
|
||||
|
||||
@@ -201,3 +201,48 @@ def test_execute_task_with_command_exception(
|
||||
|
||||
db.session.delete(report_schedule)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@patch("superset.tasks.scheduler.logger")
|
||||
def test_log_task_failure_with_sender(logger_mock):
|
||||
"""
|
||||
Test that log_task_failure logs correctly when sender is provided
|
||||
"""
|
||||
mock_task = MagicMock()
|
||||
mock_task.name = "test.task.name"
|
||||
mock_exception = Exception("Test error")
|
||||
mock_einfo = MagicMock()
|
||||
|
||||
log_task_failure(
|
||||
sender=mock_task,
|
||||
task_id="test-task-id",
|
||||
exception=mock_exception,
|
||||
einfo=mock_einfo,
|
||||
)
|
||||
|
||||
logger_mock.exception.assert_called_once_with(
|
||||
"Celery task %s failed: %s",
|
||||
"test.task.name",
|
||||
mock_exception,
|
||||
exc_info=mock_einfo,
|
||||
)
|
||||
|
||||
|
||||
@patch("superset.tasks.scheduler.logger")
|
||||
def test_log_task_failure_without_sender(logger_mock):
|
||||
"""
|
||||
Test that log_task_failure logs correctly when sender is None
|
||||
"""
|
||||
mock_exception = Exception("Test error")
|
||||
mock_einfo = MagicMock()
|
||||
|
||||
log_task_failure(
|
||||
sender=None,
|
||||
task_id="test-task-id",
|
||||
exception=mock_exception,
|
||||
einfo=mock_einfo,
|
||||
)
|
||||
|
||||
logger_mock.exception.assert_called_once_with(
|
||||
"Celery task %s failed: %s", "Unknown", mock_exception, exc_info=mock_einfo
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user