refactor(Shared_url_query): Fix shared query URL access for SQL Lab users. (#31421)

This commit is contained in:
Levis Mbote 2025-01-24 21:34:55 +03:00 committed by GitHub
parent 4b0e907c3d
commit 65c4d39c31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 97 additions and 11 deletions

View File

@ -18,6 +18,7 @@
*/ */
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import * as reactRedux from 'react-redux'; import * as reactRedux from 'react-redux';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import configureStore from 'redux-mock-store'; import configureStore from 'redux-mock-store';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
@ -242,6 +243,55 @@ describe('SavedQueryList', () => {
expect(fetchMock.calls(/saved_query\/0/, 'DELETE')).toHaveLength(1); 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(
<Provider store={store}>
<BrowserRouter>
<QueryParamProvider>
<SavedQueryList />
</QueryParamProvider>
</BrowserRouter>
</Provider>,
);
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 () => { it('shows/hides bulk actions when bulk actions is clicked', async () => {
const button = wrapper.find(Button).at(0); const button = wrapper.find(Button).at(0);
act(() => { act(() => {
@ -331,6 +381,21 @@ describe('RTL', () => {
expect(exportTooltip).toBeInTheDocument(); 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 () => { it('renders an import button in the submenu', async () => {
// Grab and assert that import saved query button is visible // Grab and assert that import saved query button is visible
const importButton = await screen.findByTestId('import-button'); const importButton = await screen.findByTestId('import-button');

View File

@ -51,7 +51,6 @@ import { TagsList } from 'src/components/Tags';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
import { commonMenuData } from 'src/features/home/commonMenuData'; import { commonMenuData } from 'src/features/home/commonMenuData';
import { QueryObjectColumns, SavedQueryObject } from 'src/views/CRUD/types'; import { QueryObjectColumns, SavedQueryObject } from 'src/views/CRUD/types';
import copyTextToClipboard from 'src/utils/copy';
import Tag from 'src/types/TagType'; import Tag from 'src/types/TagType';
import ImportModelsModal from 'src/components/ImportModal/index'; import ImportModelsModal from 'src/components/ImportModal/index';
import { ModifiedInfo } from 'src/components/AuditInfo'; import { ModifiedInfo } from 'src/components/AuditInfo';
@ -233,16 +232,31 @@ function SavedQueryList({
}; };
const copyQueryLink = useCallback( const copyQueryLink = useCallback(
(id: number) => { async (savedQuery: SavedQueryObject) => {
copyTextToClipboard(() => try {
Promise.resolve(`${window.location.origin}/sqllab?savedQueryId=${id}`), const payload = {
) dbId: savedQuery.db_id,
.then(() => { name: savedQuery.label,
addSuccessToast(t('Link Copied!')); schema: savedQuery.schema,
}) catalog: savedQuery.catalog,
.catch(() => { sql: savedQuery.sql,
addDangerToast(t('Sorry, your browser does not support copying.')); 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], [addDangerToast, addSuccessToast],
); );
@ -393,7 +407,7 @@ function SavedQueryList({
}; };
const handleEdit = ({ metaKey }: MouseEvent) => const handleEdit = ({ metaKey }: MouseEvent) =>
openInSqlLab(original.id, Boolean(metaKey)); openInSqlLab(original.id, Boolean(metaKey));
const handleCopy = () => copyQueryLink(original.id); const handleCopy = () => copyQueryLink(original);
const handleExport = () => handleBulkSavedQueryExport([original]); const handleExport = () => handleBulkSavedQueryExport([original]);
const handleDelete = () => setQueryCurrentlyDeleting(original); const handleDelete = () => setQueryCurrentlyDeleting(original);

View File

@ -70,6 +70,7 @@ export interface Dashboard {
export type SavedQueryObject = { export type SavedQueryObject = {
id: number; id: number;
catalog: string | null;
changed_on: string; changed_on: string;
changed_on_delta_humanized: string; changed_on_delta_humanized: string;
database: { database: {

View File

@ -34,6 +34,11 @@ class SqlLabPermalinkSchema(Schema):
allow_none=True, allow_none=True,
metadata={"description": "The schema name of the query"}, 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( sql = fields.String(
required=True, required=True,
allow_none=False, allow_none=False,

View File

@ -18,6 +18,7 @@ from typing import Optional, TypedDict
class SqlLabPermalinkValue(TypedDict): class SqlLabPermalinkValue(TypedDict):
catalog: Optional[str]
dbId: int dbId: int
name: str name: str
schema: Optional[str] schema: Optional[str]