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 6778d8d92c5..1420d86cec9 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 @@ -25,6 +25,10 @@ import SavedQueryList from 'src/views/CRUD/data/savedquery/SavedQueryList'; import SubMenu from 'src/components/Menu/SubMenu'; import ListView from 'src/components/ListView'; import Filters from 'src/components/ListView/Filters'; +import ActionsBar from 'src/components/ListView/ActionsBar'; +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'; @@ -34,6 +38,7 @@ const store = mockStore({}); const queriesInfoEndpoint = 'glob:*/api/v1/saved_query/_info*'; const queriesEndpoint = 'glob:*/api/v1/saved_query/?*'; +const queryEndpoint = 'glob:*/api/v1/saved_query/*'; const queriesRelatedEndpoint = 'glob:*/api/v1/saved_query/related/database?*'; const queriesDistinctEndpoint = 'glob:*/api/v1/saved_query/distinct/schema?*'; @@ -51,6 +56,7 @@ const mockqueries = [...new Array(3)].map((_, 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`, @@ -71,6 +77,9 @@ fetchMock.get(queriesEndpoint, { count: 3, }); +fetchMock.delete(queryEndpoint, {}); +fetchMock.delete(queriesEndpoint, {}); + fetchMock.get(queriesRelatedEndpoint, { count: 0, result: [], @@ -108,6 +117,51 @@ describe('SavedQueryList', () => { ); }); + it('renders ActionsBar in table', () => { + expect(wrapper.find(ActionsBar)).toExist(); + expect(wrapper.find(ActionsBar)).toHaveLength(3); + }); + + it('deletes', async () => { + act(() => { + wrapper.find('span[data-test="delete-action"]').first().props().onClick(); + }); + await waitForComponentToPaint(wrapper); + + expect( + wrapper.find(DeleteModal).first().props().description, + ).toMatchInlineSnapshot( + `"This action will permanently delete the saved query."`, + ); + + act(() => { + wrapper + .find('#delete') + .first() + .props() + .onChange({ target: { value: 'DELETE' } }); + }); + await waitForComponentToPaint(wrapper); + act(() => { + wrapper.find('button').last().props().onClick(); + }); + + await waitForComponentToPaint(wrapper); + + expect(fetchMock.calls(/saved_query\/0/, 'DELETE')).toHaveLength(1); + }); + + it('shows/hides bulk actions when bulk actions is clicked', async () => { + const button = wrapper.find(Button).at(0); + act(() => { + button.props().onClick(); + }); + await waitForComponentToPaint(wrapper); + expect(wrapper.find(IndeterminateCheckbox)).toHaveLength( + mockqueries.length + 1, // 1 for each row and 1 for select all + ); + }); + it('searches', async () => { const filtersWrapper = wrapper.find(Filters); act(() => { diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors.jsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors.jsx index 3316e76dcf0..8ee59d5541c 100644 --- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors.jsx +++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors.jsx @@ -150,8 +150,12 @@ class TabbedSqlEditors extends React.PureComponent { this.props.actions.addQueryEditor(newQueryEditor); } this.popNewTab(); - } else if (this.props.queryEditors.length === 0) { + } else if (query.new || this.props.queryEditors.length === 0) { this.newQueryEditor(); + + if (query.new) { + window.history.replaceState({}, document.title, this.state.sqlLabUrl); + } } else { const qe = this.activeQueryEditor(); const latestQuery = this.props.queries[qe.latestQueryId]; diff --git a/superset-frontend/src/components/Icon/index.tsx b/superset-frontend/src/components/Icon/index.tsx index 404a7d5fe85..2c5a4eeaea0 100644 --- a/superset-frontend/src/components/Icon/index.tsx +++ b/superset-frontend/src/components/Icon/index.tsx @@ -134,7 +134,7 @@ import { ReactComponent as WarningIcon } from 'images/icons/warning.svg'; import { ReactComponent as XLargeIcon } from 'images/icons/x-large.svg'; import { ReactComponent as XSmallIcon } from 'images/icons/x-small.svg'; -type IconName = +export type IconName = | 'alert-solid' | 'alert' | 'binoculars' diff --git a/superset-frontend/src/components/ListView/ActionsBar.tsx b/superset-frontend/src/components/ListView/ActionsBar.tsx new file mode 100644 index 00000000000..c1734f6d8f8 --- /dev/null +++ b/superset-frontend/src/components/ListView/ActionsBar.tsx @@ -0,0 +1,92 @@ +/** + * 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 React from 'react'; +import { styled } from '@superset-ui/core'; +import TooltipWrapper from 'src/components/TooltipWrapper'; +import Icon, { IconName } from 'src/components/Icon'; + +export type ActionProps = { + label: string; + tooltip?: string | React.ReactElement; + placement?: string; + icon: IconName; + onClick: () => void; +}; + +interface ActionsBarProps { + actions: Array; +} + +const StyledActions = styled.span` + white-space: nowrap; + min-width: 100px; + + svg, + i { + margin-right: 8px; + + &:hover { + path { + fill: ${({ theme }) => theme.colors.primary.base}; + } + } + } +`; + +export default function ActionsBar({ actions }: ActionsBarProps) { + return ( + + {actions.map((action, index) => { + if (action.tooltip) { + return ( + + + + + + ); + } + + return ( + + + + ); + })} + + ); +} diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx index 8832ed853d9..9148f47db3b 100644 --- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx +++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx @@ -17,8 +17,9 @@ * under the License. */ -import { t, styled } from '@superset-ui/core'; -import React, { useMemo } from 'react'; +import { SupersetClient, t, styled } from '@superset-ui/core'; +import React, { useState, useMemo } from 'react'; +import rison from 'rison'; import moment from 'moment'; import { createFetchRelated, @@ -28,10 +29,12 @@ import { import { Popover } from 'src/common/components'; import withToasts from 'src/messageToasts/enhancers/withToasts'; import { useListViewResource } from 'src/views/CRUD/hooks'; +import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu'; -import ListView, { Filters } from 'src/components/ListView'; -import TooltipWrapper from 'src/components/TooltipWrapper'; -import Icon from 'src/components/Icon'; +import ListView, { ListViewProps, Filters } from 'src/components/ListView'; +import DeleteModal from 'src/components/DeleteModal'; +import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; +import { IconName } from 'src/components/Icon'; import { commonMenuData } from 'src/views/CRUD/data/common'; const PAGE_SIZE = 25; @@ -41,7 +44,10 @@ interface SavedQueryListProps { addSuccessToast: (msg: string) => void; } -type SavedQueryObject = {}; +type SavedQueryObject = { + id: number; + label: string; +}; const StyledTableLabel = styled.div` .count { @@ -61,25 +67,126 @@ function SavedQueryList({ addSuccessToast, }: SavedQueryListProps) { const { - state: { loading, resourceCount: queryCount, resourceCollection: queries }, + state: { + loading, + resourceCount: queryCount, + resourceCollection: queries, + bulkSelectEnabled, + }, hasPerm, fetchData, - // refreshData, //TODO: add back later when editing? + toggleBulkSelect, + refreshData, } = useListViewResource( 'saved_query', t('saved_queries'), addDangerToast, ); + const [ + queryCurrentlyDeleting, + setQueryCurrentlyDeleting, + ] = useState(null); + const canCreate = hasPerm('can_add'); const canEdit = hasPerm('can_edit'); const canDelete = hasPerm('can_delete'); + const openNewQuery = () => { + window.open(`${window.location.origin}/superset/sqllab?new=true`); + }; + const menuData: SubMenuProps = { activeChild: 'Saved Queries', ...commonMenuData, }; + menuData.primaryButton = { + name: t('+ Query'), + onClick: openNewQuery, + }; + + if (canDelete) { + menuData.secondaryButton = { + name: t('Bulk Select'), + onClick: toggleBulkSelect, + }; + } + + // Action methods + const openInSqlLab = (id: number) => { + window.open(`${window.location.origin}/superset/sqllab?savedQueryId=${id}`); + }; + + const copyQueryLink = (id: number) => { + const selection: Selection | null = document.getSelection(); + + if (selection) { + selection.removeAllRanges(); + const range = document.createRange(); + const span = document.createElement('span'); + span.textContent = `${window.location.origin}/superset/sqllab?savedQueryId=${id}`; + span.style.position = 'fixed'; + span.style.top = '0'; + span.style.clip = 'rect(0, 0, 0, 0)'; + span.style.whiteSpace = 'pre'; + + document.body.appendChild(span); + range.selectNode(span); + selection.addRange(range); + + try { + if (!document.execCommand('copy')) { + throw new Error(t('Not successful')); + } + } catch (err) { + addDangerToast(t('Sorry, your browser does not support copying.')); + } + + document.body.removeChild(span); + if (selection.removeRange) { + selection.removeRange(range); + } else { + selection.removeAllRanges(); + } + + addSuccessToast(t('Link Copied!')); + } + }; + + const handleQueryDelete = ({ id, label }: SavedQueryObject) => { + SupersetClient.delete({ + endpoint: `/api/v1/saved_query/${id}`, + }).then( + () => { + refreshData(); + setQueryCurrentlyDeleting(null); + addSuccessToast(t('Deleted: %s', label)); + }, + createErrorHandler(errMsg => + addDangerToast(t('There was an issue deleting %s: %s', label, errMsg)), + ), + ); + }; + + const handleBulkQueryDelete = (queriesToDelete: SavedQueryObject[]) => { + SupersetClient.delete({ + endpoint: `/api/v1/saved_query/?q=${rison.encode( + queriesToDelete.map(({ id }) => id), + )}`, + }).then( + ({ json = {} }) => { + refreshData(); + addSuccessToast(json.message); + }, + createErrorHandler(errMsg => + addDangerToast( + t('There was an issue deleting the selected queries: %s', errMsg), + ), + ), + ); + }; + const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }]; const columns = useMemo( () => [ @@ -95,6 +202,11 @@ function SavedQueryList({ accessor: 'database', hidden: true, disableSortBy: true, + Cell: ({ + row: { + original: { database }, + }, + }: any) => `${database.database_name}`, }, { accessor: 'schema', @@ -173,75 +285,50 @@ function SavedQueryList({ { Cell: ({ row: { original } }: any) => { const handlePreview = () => {}; // openQueryPreviewModal(original); // TODO: open preview modal - const handleEdit = () => {}; // handleQueryEdit(original); // TODO: navigate to sql editor with selected query open - const handleCopy = () => {}; // TODO: copy link to clipboard - const handleDelete = () => {}; // openQueryDeleteModal(original); + const handleEdit = () => { + openInSqlLab(original.id); + }; + const handleCopy = () => { + copyQueryLink(original.id); + }; + const handleDelete = () => setQueryCurrentlyDeleting(original); // openQueryDeleteModal(original); - return ( - - - - - - - {canEdit && ( - - - - - - )} - - - - - - {canDelete && ( - - - - - - )} - - ); + const actions = [ + { + label: 'preview-action', + tooltip: t('Query preview'), + placement: 'bottom', + icon: 'binoculars' as IconName, + onClick: handlePreview, + }, + canEdit + ? { + label: 'edit-action', + tooltip: t('Edit query'), + placement: 'bottom', + icon: 'edit' as IconName, + onClick: handleEdit, + } + : null, + { + label: 'copy-action', + tooltip: t('Copy query URL'), + placement: 'bottom', + icon: 'copy' as IconName, + onClick: handleCopy, + }, + canDelete + ? { + label: 'delete-action', + tooltip: t('Delete query'), + placement: 'bottom', + icon: 'trash' as IconName, + onClick: handleDelete, + } + : null, + ].filter(item => !!item); + + return ; }, Header: t('Actions'), id: 'actions', @@ -299,17 +386,56 @@ function SavedQueryList({ return ( <> - - className="saved_query-list-view" - columns={columns} - count={queryCount} - data={queries} - fetchData={fetchData} - filters={filters} - initialSort={initialSort} - loading={loading} - pageSize={PAGE_SIZE} - /> + {queryCurrentlyDeleting && ( + { + if (queryCurrentlyDeleting) { + handleQueryDelete(queryCurrentlyDeleting); + } + }} + onHide={() => setQueryCurrentlyDeleting(null)} + open + title={t('Delete Query?')} + /> + )} + + {confirmDelete => { + const bulkActions: ListViewProps['bulkActions'] = canDelete + ? [ + { + key: 'delete', + name: t('Delete'), + onSelect: confirmDelete, + type: 'danger', + }, + ] + : []; + + return ( + + className="saved_query-list-view" + columns={columns} + count={queryCount} + data={queries} + fetchData={fetchData} + filters={filters} + initialSort={initialSort} + loading={loading} + pageSize={PAGE_SIZE} + bulkActions={bulkActions} + bulkSelectEnabled={bulkSelectEnabled} + disableBulkSelect={toggleBulkSelect} + /> + ); + }} + ); } diff --git a/superset/queries/saved_queries/api.py b/superset/queries/saved_queries/api.py index e8c72e28936..a84ee722ff7 100644 --- a/superset/queries/saved_queries/api.py +++ b/superset/queries/saved_queries/api.py @@ -82,6 +82,7 @@ class SavedQueryRestApi(BaseSupersetModelRestApi): "database.id", "db_id", "description", + "id", "label", "schema", "sql", diff --git a/tests/queries/saved_queries/api_tests.py b/tests/queries/saved_queries/api_tests.py index b748d03352b..b68ff086168 100644 --- a/tests/queries/saved_queries/api_tests.py +++ b/tests/queries/saved_queries/api_tests.py @@ -122,6 +122,7 @@ class TestSavedQueryApi(SupersetTestCase): "database", "db_id", "description", + "id", "label", "schema", "sql",