diff --git a/superset-frontend/src/pages/SavedQueryList/SavedQueryList.test.jsx b/superset-frontend/src/pages/SavedQueryList/SavedQueryList.test.jsx index 952b234e2..30442e873 100644 --- a/superset-frontend/src/pages/SavedQueryList/SavedQueryList.test.jsx +++ b/superset-frontend/src/pages/SavedQueryList/SavedQueryList.test.jsx @@ -18,6 +18,7 @@ */ import thunk from 'redux-thunk'; import * as reactRedux from 'react-redux'; +import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; import configureStore from 'redux-mock-store'; import fetchMock from 'fetch-mock'; @@ -242,6 +243,55 @@ describe('SavedQueryList', () => { expect(fetchMock.calls(/saved_query\/0/, 'DELETE')).toHaveLength(1); }); + it('copies a query link when the API succeeds', async () => { + Object.assign(navigator, { + clipboard: { + writeText: jest.fn(), + }, + }); + + fetchMock.get('glob:*/api/v1/saved_query', { + result: [ + { + id: 1, + label: 'Test Query', + db_id: 1, + schema: 'public', + sql: 'SELECT * FROM table', + }, + ], + count: 1, + }); + fetchMock.post('glob:*/api/v1/sqllab/permalink', { + body: { url: 'http://example.com/permalink' }, + status: 200, + }); + + render( + + + + + + + , + ); + + const copyActionButton = await waitFor( + () => screen.getAllByTestId('copy-action')[0], + ); + userEvent.hover(copyActionButton); + + userEvent.click(copyActionButton); + await waitFor(() => { + expect(fetchMock.calls('glob:*/api/v1/sqllab/permalink').length).toBe(1); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + 'http://example.com/permalink', + ); + }); + it('shows/hides bulk actions when bulk actions is clicked', async () => { const button = wrapper.find(Button).at(0); act(() => { @@ -331,6 +381,21 @@ describe('RTL', () => { expect(exportTooltip).toBeInTheDocument(); }); + it('renders a copy button in the actions bar', async () => { + // Grab copy action button and mock mouse hovering over it + const copyActionButton = screen.getAllByTestId('copy-action')[0]; + userEvent.hover(copyActionButton); + + // Wait for the tooltip to pop up + await screen.findByRole('tooltip'); + + // Grab and assert that "Copy query URl" tooltip is in the document + const copyTooltip = screen.getByRole('tooltip', { + name: /Copy query URL/i, + }); + expect(copyTooltip).toBeInTheDocument(); + }); + it('renders an import button in the submenu', async () => { // Grab and assert that import saved query button is visible const importButton = await screen.findByTestId('import-button'); diff --git a/superset-frontend/src/pages/SavedQueryList/index.tsx b/superset-frontend/src/pages/SavedQueryList/index.tsx index a65e6bcdf..052815f63 100644 --- a/superset-frontend/src/pages/SavedQueryList/index.tsx +++ b/superset-frontend/src/pages/SavedQueryList/index.tsx @@ -51,7 +51,6 @@ import { TagsList } from 'src/components/Tags'; import { Tooltip } from 'src/components/Tooltip'; import { commonMenuData } from 'src/features/home/commonMenuData'; import { QueryObjectColumns, SavedQueryObject } from 'src/views/CRUD/types'; -import copyTextToClipboard from 'src/utils/copy'; import Tag from 'src/types/TagType'; import ImportModelsModal from 'src/components/ImportModal/index'; import { ModifiedInfo } from 'src/components/AuditInfo'; @@ -233,16 +232,31 @@ function SavedQueryList({ }; const copyQueryLink = useCallback( - (id: number) => { - copyTextToClipboard(() => - Promise.resolve(`${window.location.origin}/sqllab?savedQueryId=${id}`), - ) - .then(() => { - addSuccessToast(t('Link Copied!')); - }) - .catch(() => { - addDangerToast(t('Sorry, your browser does not support copying.')); + async (savedQuery: SavedQueryObject) => { + try { + const payload = { + dbId: savedQuery.db_id, + name: savedQuery.label, + schema: savedQuery.schema, + catalog: savedQuery.catalog, + sql: savedQuery.sql, + autorun: false, + templateParams: null, + }; + + const response = await SupersetClient.post({ + endpoint: '/api/v1/sqllab/permalink', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), }); + + const { url: permalink } = response.json; + + await navigator.clipboard.writeText(permalink); + addSuccessToast(t('Link Copied!')); + } catch (error) { + addDangerToast(t('There was an error generating the permalink.')); + } }, [addDangerToast, addSuccessToast], ); @@ -393,7 +407,7 @@ function SavedQueryList({ }; const handleEdit = ({ metaKey }: MouseEvent) => openInSqlLab(original.id, Boolean(metaKey)); - const handleCopy = () => copyQueryLink(original.id); + const handleCopy = () => copyQueryLink(original); const handleExport = () => handleBulkSavedQueryExport([original]); const handleDelete = () => setQueryCurrentlyDeleting(original); diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts index fd9371355..022871136 100644 --- a/superset-frontend/src/views/CRUD/types.ts +++ b/superset-frontend/src/views/CRUD/types.ts @@ -70,6 +70,7 @@ export interface Dashboard { export type SavedQueryObject = { id: number; + catalog: string | null; changed_on: string; changed_on_delta_humanized: string; database: { diff --git a/superset/sqllab/permalink/schemas.py b/superset/sqllab/permalink/schemas.py index d89c2b579..9fbbf8866 100644 --- a/superset/sqllab/permalink/schemas.py +++ b/superset/sqllab/permalink/schemas.py @@ -34,6 +34,11 @@ class SqlLabPermalinkSchema(Schema): allow_none=True, metadata={"description": "The schema name of the query"}, ) + catalog = fields.String( + required=False, + allow_none=True, + metadata={"description": "The catalog name of the query"}, + ) sql = fields.String( required=True, allow_none=False, diff --git a/superset/sqllab/permalink/types.py b/superset/sqllab/permalink/types.py index adc127da1..0e0aa48ab 100644 --- a/superset/sqllab/permalink/types.py +++ b/superset/sqllab/permalink/types.py @@ -18,6 +18,7 @@ from typing import Optional, TypedDict class SqlLabPermalinkValue(TypedDict): + catalog: Optional[str] dbId: int name: str schema: Optional[str]