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 ec8b80ed8d..019c8c3fa8 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 @@ -75,6 +75,42 @@ const mockqueries = [...new Array(3)].map((_, i) => ({ ], })); +// ---------- For import testing ---------- +// Create an one more mocked query than the original mocked query array +const mockOneMoreQuery = [...new Array(mockqueries.length + 1)].map((_, i) => ({ + created_by: { + id: i, + first_name: `user`, + last_name: `${i}`, + }, + created_on: `${i}-2020`, + database: { + database_name: `db ${i}`, + id: i, + }, + changed_on_delta_humanized: '1 day ago', + db_id: i, + description: `SQL for ${i}`, + id: i, + label: `query ${i}`, + schema: 'public', + sql: `SELECT ${i} FROM table`, + sql_tables: [ + { + catalog: null, + schema: null, + table: `${i}`, + }, + ], +})); +// Grab the last mocked query, to mock import +const mockNewImportQuery = mockOneMoreQuery.pop(); +// Create a new file out of mocked import query to mock upload +const mockImportFile = new File( + [mockNewImportQuery], + 'saved_query_import_mock.json', +); + fetchMock.get(queriesInfoEndpoint, { permissions: ['can_write', 'can_read'], }); @@ -237,7 +273,7 @@ describe('RTL', () => { 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]; + const exportActionButton = screen.getAllByRole('button')[18]; userEvent.hover(exportActionButton); // Wait for the tooltip to pop up @@ -252,9 +288,42 @@ describe('RTL', () => { it('runs handleBulkSavedQueryExport when export is clicked', () => { // Grab Export action button and mock mouse clicking it - const exportActionButton = screen.getAllByRole('button')[17]; + const exportActionButton = screen.getAllByRole('button')[18]; userEvent.click(exportActionButton); expect(handleBulkSavedQueryExport).toHaveBeenCalled(); }); + + it('renders an import button in the submenu', () => { + // Grab and assert that import saved query button is visible + const importSavedQueryButton = screen.getAllByRole('button')[2]; + expect(importSavedQueryButton).toBeVisible(); + }); + + it('renders an import model when import button is clicked', async () => { + // Grab and click import saved query button to reveal modal + const importSavedQueryButton = screen.getAllByRole('button')[2]; + userEvent.click(importSavedQueryButton); + + // Grab and assert that saved query import modal's heading is visible + const importSavedQueryModalHeading = screen.getByRole('heading', { + name: /import saved query/i, + }); + expect(importSavedQueryModalHeading).toBeVisible(); + }); + + it('imports a saved query', () => { + // Grab and click import saved query button to reveal modal + const importSavedQueryButton = screen.getAllByRole('button')[2]; + userEvent.click(importSavedQueryButton); + + // Grab "Choose File" input from import modal + const chooseFileInput = screen.getByLabelText(/file\*/i); + // Upload mocked import file + userEvent.upload(chooseFileInput, mockImportFile); + + expect(chooseFileInput.files[0]).toStrictEqual(mockImportFile); + expect(chooseFileInput.files.item(0)).toStrictEqual(mockImportFile); + expect(chooseFileInput.files).toHaveLength(1); + }); }); diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx index fe0312eab4..9f90bf559b 100644 --- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx +++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx @@ -42,9 +42,23 @@ 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 ImportModelsModal from 'src/components/ImportModal/index'; +import Icons from 'src/components/Icons'; import SavedQueryPreviewModal from './SavedQueryPreviewModal'; const PAGE_SIZE = 25; +const PASSWORDS_NEEDED_MESSAGE = t( + 'The passwords for the databases below are needed in order to ' + + 'import them together with the saved queries. Please note that the ' + + '"Secure Extra" and "Certificate" sections of ' + + 'the database configuration are not present in export files, and ' + + 'should be added manually after the import if they are needed.', +); +const CONFIRM_OVERWRITE_MESSAGE = t( + 'You are importing one or more saved queries that already exist. ' + + 'Overwriting might cause you to lose some of your work. Are you ' + + 'sure you want to overwrite?', +); interface SavedQueryListProps { addDangerToast: (msg: string) => void; @@ -96,6 +110,21 @@ function SavedQueryList({ savedQueryCurrentlyPreviewing, setSavedQueryCurrentlyPreviewing, ] = useState(null); + const [importingSavedQuery, showImportModal] = useState(false); + const [passwordFields, setPasswordFields] = useState([]); + + const openSavedQueryImportModal = () => { + showImportModal(true); + }; + + const closeSavedQueryImportModal = () => { + showImportModal(false); + }; + + const handleSavedQueryImport = () => { + showImportModal(false); + refreshData(); + }; const canEdit = hasPerm('can_write'); const canDelete = hasPerm('can_write'); @@ -149,6 +178,15 @@ function SavedQueryList({ buttonStyle: 'primary', }); + if (isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT)) { + subMenuButtons.push({ + name: , + buttonStyle: 'link', + onClick: openSavedQueryImportModal, + 'data-test': 'import-button', + }); + } + menuData.buttons = subMenuButtons; // Action methods @@ -476,6 +514,20 @@ function SavedQueryList({ ); }} + + ); } diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts index 6d369d864c..a312c40004 100644 --- a/superset-frontend/src/views/CRUD/types.ts +++ b/superset-frontend/src/views/CRUD/types.ts @@ -123,7 +123,12 @@ export enum QueryObjectColumns { tracking_url = 'tracking_url', } -export type ImportResourceName = 'chart' | 'dashboard' | 'database' | 'dataset'; +export type ImportResourceName = + | 'chart' + | 'dashboard' + | 'database' + | 'dataset' + | 'saved_query'; export type DatabaseObject = { allow_run_async?: boolean; diff --git a/superset-frontend/src/views/CRUD/utils.tsx b/superset-frontend/src/views/CRUD/utils.tsx index 4c5638616e..4096a96534 100644 --- a/superset-frontend/src/views/CRUD/utils.tsx +++ b/superset-frontend/src/views/CRUD/utils.tsx @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ + import { t, SupersetClient,