diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json
index 524237ac946..66d1da38dae 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -23908,6 +23908,7 @@
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.9.tgz",
"integrity": "sha512-oeyj2H3EjjonWcFjD5NvZNE9Rqe4UW+nQBU2HNeKw0koVLEFIhtyETyAakeAM3de7Z/SW5kcA+fZUait9EApnw==",
"dev": true,
+ "hasInstallScript": true,
"optional": true,
"os": [
"darwin"
@@ -54971,6 +54972,7 @@
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"dev": true,
+ "hasInstallScript": true,
"optional": true,
"os": [
"darwin"
diff --git a/superset-frontend/spec/javascripts/views/CRUD/data/savedquery/SavedQueryList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/data/savedquery/SavedQueryList_spec.jsx
index a57cf9b54af..e1e5f6e10bf 100644
--- a/superset-frontend/spec/javascripts/views/CRUD/data/savedquery/SavedQueryList_spec.jsx
+++ b/superset-frontend/spec/javascripts/views/CRUD/data/savedquery/SavedQueryList_spec.jsx
@@ -22,6 +22,11 @@ import configureStore from 'redux-mock-store';
import { Provider } from 'react-redux';
import fetchMock from 'fetch-mock';
import { styledMount as mount } from 'spec/helpers/theming';
+import { render, screen, cleanup } from 'spec/helpers/testing-library';
+import userEvent from '@testing-library/user-event';
+import { QueryParamProvider } from 'use-query-params';
+import { act } from 'react-dom/test-utils';
+import * as featureFlags from 'src/featureFlags';
import SavedQueryList from 'src/views/CRUD/data/savedquery/SavedQueryList';
import SubMenu from 'src/components/Menu/SubMenu';
import ListView from 'src/components/ListView';
@@ -31,7 +36,6 @@ import DeleteModal from 'src/components/DeleteModal';
import Button from 'src/components/Button';
import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
-import { act } from 'react-dom/test-utils';
// store needed for withToasts(DatabaseList)
const mockStore = configureStore([thunk]);
@@ -71,7 +75,7 @@ const mockqueries = [...new Array(3)].map((_, i) => ({
}));
fetchMock.get(queriesInfoEndpoint, {
- permissions: ['can_write'],
+ permissions: ['can_write', 'can_read'],
});
fetchMock.get(queriesEndpoint, {
result: mockqueries,
@@ -179,3 +183,66 @@ describe('SavedQueryList', () => {
);
});
});
+
+describe('RTL', () => {
+ async function renderAndWait() {
+ const mounted = act(async () => {
+ render(
+
+
+
+
+ ,
+ );
+ });
+
+ return mounted;
+ }
+
+ let isFeatureEnabledMock;
+ beforeEach(async () => {
+ isFeatureEnabledMock = jest
+ .spyOn(featureFlags, 'isFeatureEnabled')
+ .mockImplementation(() => true);
+ await renderAndWait();
+ });
+
+ afterEach(() => {
+ cleanup();
+ isFeatureEnabledMock.mockRestore();
+ });
+ it('renders an export button in the bulk actions', () => {
+ // Grab and click the "Bulk Select" button to expose checkboxes
+ const bulkSelectButton = screen.getByRole('button', {
+ name: /bulk select/i,
+ });
+ userEvent.click(bulkSelectButton);
+
+ // Grab and click the "toggle all" checkbox to expose export button
+ const selectAllCheckbox = screen.getByRole('checkbox', {
+ name: /toggle all rows selected/i,
+ });
+ userEvent.click(selectAllCheckbox);
+
+ // Grab and assert that export button is visible
+ const exportButton = screen.getByRole('button', {
+ name: /export/i,
+ });
+ expect(exportButton).toBeVisible();
+ });
+
+ it('renders an export button in the actions bar', async () => {
+ // Grab Export action button and mock mouse hovering over it
+ const exportActionButton = screen.getAllByRole('button')[17];
+ userEvent.hover(exportActionButton);
+
+ // Wait for the tooltip to pop up
+ await screen.findByRole('tooltip');
+
+ // Grab and assert that "Export Query" tooltip is in the document
+ const exportTooltip = screen.getByRole('tooltip', {
+ name: /export query/i,
+ });
+ expect(exportTooltip).toBeInTheDocument();
+ });
+});
diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx
index 527bd76460a..352aa8eec3f 100644
--- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx
+++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx
@@ -25,6 +25,7 @@ import {
createFetchRelated,
createFetchDistinct,
createErrorHandler,
+ handleBulkDashboardExport,
} from 'src/views/CRUD/utils';
import Popover from 'src/components/Popover';
import withToasts from 'src/messageToasts/enhancers/withToasts';
@@ -40,6 +41,7 @@ import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
import { commonMenuData } from 'src/views/CRUD/data/common';
import { SavedQueryObject } from 'src/views/CRUD/types';
import copyTextToClipboard from 'src/utils/copy';
+import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
import SavedQueryPreviewModal from './SavedQueryPreviewModal';
const PAGE_SIZE = 25;
@@ -97,6 +99,8 @@ function SavedQueryList({
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
+ const canExport =
+ hasPerm('can_read') && isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT);
const openNewQuery = () => {
window.open(`${window.location.origin}/superset/sqllab?new=true`);
@@ -229,7 +233,7 @@ function SavedQueryList({
},
}: any) => {
const names = tables.map((table: any) => table.table);
- const main = names.length > 0 ? names.shift() : '';
+ const main = names?.shift() || '';
if (names.length) {
return (
@@ -300,12 +304,9 @@ function SavedQueryList({
const handlePreview = () => {
handleSavedQueryPreview(original.id);
};
- const handleEdit = () => {
- openInSqlLab(original.id);
- };
- const handleCopy = () => {
- copyQueryLink(original.id);
- };
+ const handleEdit = () => openInSqlLab(original.id);
+ const handleCopy = () => copyQueryLink(original.id);
+ const handleExport = () => handleBulkDashboardExport([original]);
const handleDelete = () => setQueryCurrentlyDeleting(original);
const actions = [
@@ -316,15 +317,13 @@ function SavedQueryList({
icon: 'Binoculars',
onClick: handlePreview,
},
- canEdit
- ? {
- label: 'edit-action',
- tooltip: t('Edit query'),
- placement: 'bottom',
- icon: 'Edit',
- onClick: handleEdit,
- }
- : null,
+ canEdit && {
+ label: 'edit-action',
+ tooltip: t('Edit query'),
+ placement: 'bottom',
+ icon: 'Edit',
+ onClick: handleEdit,
+ },
{
label: 'copy-action',
tooltip: t('Copy query URL'),
@@ -332,15 +331,20 @@ function SavedQueryList({
icon: 'Copy',
onClick: handleCopy,
},
- canDelete
- ? {
- label: 'delete-action',
- tooltip: t('Delete query'),
- placement: 'bottom',
- icon: 'Trash',
- onClick: handleDelete,
- }
- : null,
+ canExport && {
+ label: 'export-action',
+ tooltip: t('Export query'),
+ placement: 'bottom',
+ icon: 'Share',
+ onClick: handleExport,
+ },
+ canDelete && {
+ label: 'delete-action',
+ tooltip: t('Delete query'),
+ placement: 'bottom',
+ icon: 'Trash',
+ onClick: handleDelete,
+ },
].filter(item => !!item);
return ;
@@ -350,7 +354,7 @@ function SavedQueryList({
disableSortBy: true,
},
],
- [canDelete, canEdit, copyQueryLink, handleSavedQueryPreview],
+ [canDelete, canEdit, canExport, copyQueryLink, handleSavedQueryPreview],
);
const filters: Filters = useMemo(
@@ -436,17 +440,23 @@ function SavedQueryList({
onConfirm={handleBulkQueryDelete}
>
{confirmDelete => {
- const bulkActions: ListViewProps['bulkActions'] = canDelete
- ? [
- {
- key: 'delete',
- name: t('Delete'),
- onSelect: confirmDelete,
- type: 'danger',
- },
- ]
- : [];
-
+ const bulkActions: ListViewProps['bulkActions'] = [];
+ if (canDelete) {
+ bulkActions.push({
+ key: 'delete',
+ name: t('Delete'),
+ onSelect: confirmDelete,
+ type: 'danger',
+ });
+ }
+ if (canExport) {
+ bulkActions.push({
+ key: 'export',
+ name: t('Export'),
+ type: 'primary',
+ onSelect: handleBulkDashboardExport,
+ });
+ }
return (
className="saved_query-list-view"