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"