feat(sqllab): SPA migration (#25151)

This commit is contained in:
JUST.in DO IT 2023-10-04 12:21:41 -07:00 committed by GitHub
parent af661ceee2
commit 5ab1e7eae4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 518 additions and 361 deletions

View File

@ -20,7 +20,7 @@ import { selectResultsTab } from './sqllab.helper';
describe.skip('SqlLab datasource panel', () => { describe.skip('SqlLab datasource panel', () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/superset/sqllab'); cy.visit('/sqllab');
}); });
// TODO the test bellow is flaky, and has been disabled for the time being // TODO the test bellow is flaky, and has been disabled for the time being

View File

@ -25,7 +25,7 @@ function parseClockStr(node: JQuery) {
describe('SqlLab query panel', () => { describe('SqlLab query panel', () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/superset/sqllab'); cy.visit('/sqllab');
}); });
it.skip('supports entering and running a query', () => { it.skip('supports entering and running a query', () => {

View File

@ -19,7 +19,7 @@
describe('SqlLab view', () => { describe('SqlLab view', () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/superset/sqllab'); cy.visit('/sqllab');
}); });
it('should load the SqlLab', () => { it('should load the SqlLab', () => {

View File

@ -18,7 +18,7 @@
*/ */
describe('SqlLab query tabs', () => { describe('SqlLab query tabs', () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/superset/sqllab'); cy.visit('/sqllab');
}); });
const tablistSelector = '[data-test="sql-editor-tabs"] > [role="tablist"]'; const tablistSelector = '[data-test="sql-editor-tabs"] > [role="tablist"]';

View File

@ -77984,7 +77984,7 @@
"@mapbox/geojson-extent": "^1.0.1", "@mapbox/geojson-extent": "^1.0.1",
"@math.gl/web-mercator": "^3.2.2", "@math.gl/web-mercator": "^3.2.2",
"@types/d3-array": "^2.0.0", "@types/d3-array": "^2.0.0",
"@types/mapbox__geojson-extent": "*", "@types/mapbox__geojson-extent": "^1.0.0",
"@types/underscore": "^1.11.6", "@types/underscore": "^1.11.6",
"@types/urijs": "^1.19.19", "@types/urijs": "^1.19.19",
"bootstrap-slider": "^10.0.0", "bootstrap-slider": "^10.0.0",

View File

@ -29,7 +29,6 @@ import messageToasts from 'src/components/MessageToasts/reducers';
import saveModal from 'src/explore/reducers/saveModalReducer'; import saveModal from 'src/explore/reducers/saveModalReducer';
import explore from 'src/explore/reducers/exploreReducer'; import explore from 'src/explore/reducers/exploreReducer';
import sqlLab from 'src/SqlLab/reducers/sqlLab'; import sqlLab from 'src/SqlLab/reducers/sqlLab';
import localStorageUsageInKilobytes from 'src/SqlLab/reducers/localStorageUsage';
import reports from 'src/features/reports/ReportModal/reducer'; import reports from 'src/features/reports/ReportModal/reducer';
import getBootstrapData from 'src/utils/getBootstrapData'; import getBootstrapData from 'src/utils/getBootstrapData';
@ -59,7 +58,7 @@ export default {
saveModal, saveModal,
explore, explore,
sqlLab, sqlLab,
localStorageUsageInKilobytes, localStorageUsageInKilobytes: noopReducer(0),
reports, reports,
common: noopReducer(common), common: noopReducer(common),
user: noopReducer(user), user: noopReducer(user),

View File

@ -1,84 +0,0 @@
/**
* 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 { Provider } from 'react-redux';
import { hot } from 'react-hot-loader/root';
import {
FeatureFlag,
ThemeProvider,
initFeatureFlags,
isFeatureEnabled,
} from '@superset-ui/core';
import { GlobalStyles } from 'src/GlobalStyles';
import { setupStore, userReducer } from 'src/views/store';
import setupExtensions from 'src/setup/setupExtensions';
import getBootstrapData from 'src/utils/getBootstrapData';
import { persistSqlLabStateEnhancer } from 'src/SqlLab/middlewares/persistSqlLabStateEnhancer';
import getInitialState from './reducers/getInitialState';
import { reducers } from './reducers/index';
import App from './components/App';
import { rehydratePersistedState } from './utils/reduxStateToLocalStorageHelper';
import setupApp from '../setup/setupApp';
import '../assets/stylesheets/reactable-pagination.less';
import { theme } from '../preamble';
import { SqlLabGlobalStyles } from './SqlLabGlobalStyles';
setupApp();
setupExtensions();
const bootstrapData = getBootstrapData();
initFeatureFlags(bootstrapData.common.feature_flags);
const initialState = getInitialState(bootstrapData);
export const store = setupStore({
initialState,
rootReducers: { ...reducers, user: userReducer },
...(!isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) && {
enhancers: [persistSqlLabStateEnhancer],
}),
});
rehydratePersistedState(store.dispatch, initialState);
// Highlight the navbar menu
const menus = document.querySelectorAll('.nav.navbar-nav li.dropdown');
const sqlLabMenu = Array.prototype.slice
.apply(menus)
.find(element => element.innerText.trim() === 'SQL Lab');
if (sqlLabMenu) {
const classes = sqlLabMenu.getAttribute('class');
if (classes.indexOf('active') === -1) {
sqlLabMenu.setAttribute('class', `${classes} active`);
}
}
const Application = () => (
<Provider store={store}>
<ThemeProvider theme={theme}>
<GlobalStyles />
<SqlLabGlobalStyles />
<App />
</ThemeProvider>
</Provider>
);
export default hot(Application);

View File

@ -28,7 +28,7 @@ import { schemaApiUtil } from 'src/hooks/apiResources/schemas';
import { tableApiUtil } from 'src/hooks/apiResources/tables'; import { tableApiUtil } from 'src/hooks/apiResources/tables';
import { addTable } from 'src/SqlLab/actions/sqlLab'; import { addTable } from 'src/SqlLab/actions/sqlLab';
import { initialState } from 'src/SqlLab/fixtures'; import { initialState } from 'src/SqlLab/fixtures';
import { reducers } from 'src/SqlLab/reducers'; import reducers from 'spec/helpers/reducerIndex';
import { import {
SCHEMA_AUTOCOMPLETE_SCORE, SCHEMA_AUTOCOMPLETE_SCORE,
TABLE_AUTOCOMPLETE_SCORE, TABLE_AUTOCOMPLETE_SCORE,

View File

@ -17,12 +17,13 @@
* under the License. * under the License.
*/ */
import React from 'react'; import React from 'react';
import { combineReducers } from 'redux';
import configureStore from 'redux-mock-store'; import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import { render } from 'spec/helpers/testing-library'; import { render } from 'spec/helpers/testing-library';
import App from 'src/SqlLab/components/App'; import App from 'src/SqlLab/components/App';
import sqlLabReducer from 'src/SqlLab/reducers/index'; import reducers from 'spec/helpers/reducerIndex';
import { LOCALSTORAGE_MAX_USAGE_KB } from 'src/SqlLab/constants'; import { LOCALSTORAGE_MAX_USAGE_KB } from 'src/SqlLab/constants';
import { LOG_EVENT } from 'src/logger/actions'; import { LOG_EVENT } from 'src/logger/actions';
import { import {
@ -37,6 +38,8 @@ jest.mock('src/SqlLab/components/QueryAutoRefresh', () => () => (
<div data-test="mock-query-auto-refresh" /> <div data-test="mock-query-auto-refresh" />
)); ));
const sqlLabReducer = combineReducers(reducers);
describe('SqlLab App', () => { describe('SqlLab App', () => {
const middlewares = [thunk]; const middlewares = [thunk];
const mockStore = configureStore(middlewares); const mockStore = configureStore(middlewares);

View File

@ -20,9 +20,9 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Redirect } from 'react-router-dom';
import { css, styled, t } from '@superset-ui/core'; import { css, styled, t } from '@superset-ui/core';
import throttle from 'lodash/throttle'; import throttle from 'lodash/throttle';
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
import { import {
LOCALSTORAGE_MAX_USAGE_KB, LOCALSTORAGE_MAX_USAGE_KB,
LOCALSTORAGE_WARNING_THRESHOLD, LOCALSTORAGE_WARNING_THRESHOLD,
@ -186,7 +186,14 @@ class App extends React.PureComponent {
render() { render() {
const { queries, queriesLastUpdate } = this.props; const { queries, queriesLastUpdate } = this.props;
if (this.state.hash && this.state.hash === '#search') { if (this.state.hash && this.state.hash === '#search') {
return window.location.replace('/superset/sqllab/history/'); return (
<Redirect
to={{
pathname: '/sqllab/history/',
replace: true,
}}
/>
);
} }
return ( return (
<SqlLabStyles data-test="SqlLabApp" className="App SqlLab"> <SqlLabStyles data-test="SqlLabApp" className="App SqlLab">
@ -195,7 +202,6 @@ class App extends React.PureComponent {
queriesLastUpdate={queriesLastUpdate} queriesLastUpdate={queriesLastUpdate}
/> />
<TabbedSqlEditors /> <TabbedSqlEditors />
<ToastContainer />
</SqlLabStyles> </SqlLabStyles>
); );
} }

View File

@ -61,7 +61,7 @@ interface QueryTableProps {
} }
const openQuery = (id: number) => { const openQuery = (id: number) => {
const url = `/superset/sqllab?queryId=${id}`; const url = `/sqllab?queryId=${id}`;
window.open(url); window.open(url);
}; };

View File

@ -20,7 +20,7 @@ import React from 'react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { fireEvent, render, waitFor } from 'spec/helpers/testing-library'; import { fireEvent, render, waitFor } from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
import { reducers } from 'src/SqlLab/reducers'; import reducers from 'spec/helpers/reducerIndex';
import SqlEditor from 'src/SqlLab/components/SqlEditor'; import SqlEditor from 'src/SqlLab/components/SqlEditor';
import { setupStore } from 'src/views/store'; import { setupStore } from 'src/views/store';
import { import {

View File

@ -26,7 +26,7 @@ import SqlEditorLeftBar from 'src/SqlLab/components/SqlEditorLeftBar';
import { table, initialState, defaultQueryEditor } from 'src/SqlLab/fixtures'; import { table, initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
import { api } from 'src/hooks/apiResources/queryApi'; import { api } from 'src/hooks/apiResources/queryApi';
import { setupStore } from 'src/views/store'; import { setupStore } from 'src/views/store';
import { reducers } from 'src/SqlLab/reducers'; import reducers from 'spec/helpers/reducerIndex';
const mockedProps = { const mockedProps = {
tables: [table], tables: [table],

View File

@ -110,23 +110,17 @@ describe('TabbedSqlEditors', () => {
it('should handle id', async () => { it('should handle id', async () => {
uriStub.returns({ id: 1 }); uriStub.returns({ id: 1 });
await mountWithAct(); await mountWithAct();
expect(window.history.replaceState.getCall(0).args[2]).toBe( expect(window.history.replaceState.getCall(0).args[2]).toBe('/sqllab');
'/superset/sqllab',
);
}); });
it('should handle savedQueryId', async () => { it('should handle savedQueryId', async () => {
uriStub.returns({ savedQueryId: 1 }); uriStub.returns({ savedQueryId: 1 });
await mountWithAct(); await mountWithAct();
expect(window.history.replaceState.getCall(0).args[2]).toBe( expect(window.history.replaceState.getCall(0).args[2]).toBe('/sqllab');
'/superset/sqllab',
);
}); });
it('should handle sql', async () => { it('should handle sql', async () => {
uriStub.returns({ sql: 1, dbid: 1 }); uriStub.returns({ sql: 1, dbid: 1 });
await mountWithAct(); await mountWithAct();
expect(window.history.replaceState.getCall(0).args[2]).toBe( expect(window.history.replaceState.getCall(0).args[2]).toBe('/sqllab');
'/superset/sqllab',
);
}); });
it('should handle custom url params', async () => { it('should handle custom url params', async () => {
uriStub.returns({ uriStub.returns({
@ -137,7 +131,7 @@ describe('TabbedSqlEditors', () => {
}); });
await mountWithAct(); await mountWithAct();
expect(window.history.replaceState.getCall(0).args[2]).toBe( expect(window.history.replaceState.getCall(0).args[2]).toBe(
'/superset/sqllab?custom_value=str&extra_attr1=true', '/sqllab?custom_value=str&extra_attr1=true',
); );
}); });
}); });

View File

@ -29,6 +29,7 @@ import { detectOS } from 'src/utils/common';
import * as Actions from 'src/SqlLab/actions/sqlLab'; import * as Actions from 'src/SqlLab/actions/sqlLab';
import { EmptyStateBig } from 'src/components/EmptyState'; import { EmptyStateBig } from 'src/components/EmptyState';
import getBootstrapData from 'src/utils/getBootstrapData'; import getBootstrapData from 'src/utils/getBootstrapData';
import { locationContext } from 'src/pages/SqlLab/LocationContext';
import SqlEditor from '../SqlEditor'; import SqlEditor from '../SqlEditor';
import SqlEditorTabHeader from '../SqlEditorTabHeader'; import SqlEditorTabHeader from '../SqlEditorTabHeader';
@ -75,7 +76,7 @@ const userOS = detectOS();
class TabbedSqlEditors extends React.PureComponent { class TabbedSqlEditors extends React.PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
const sqlLabUrl = '/superset/sqllab'; const sqlLabUrl = '/sqllab';
this.state = { this.state = {
sqlLabUrl, sqlLabUrl,
}; };
@ -132,6 +133,7 @@ class TabbedSqlEditors extends React.PureComponent {
new: isNewQuery, new: isNewQuery,
...urlParams ...urlParams
} = { } = {
...this.context.requestedQuery,
...bootstrapData.requested_query, ...bootstrapData.requested_query,
...queryParameters, ...queryParameters,
}; };
@ -332,6 +334,7 @@ class TabbedSqlEditors extends React.PureComponent {
} }
TabbedSqlEditors.propTypes = propTypes; TabbedSqlEditors.propTypes = propTypes;
TabbedSqlEditors.defaultProps = defaultProps; TabbedSqlEditors.defaultProps = defaultProps;
TabbedSqlEditors.contextType = locationContext;
function mapStateToProps({ sqlLab, common }) { function mapStateToProps({ sqlLab, common }) {
return { return {

View File

@ -1,23 +0,0 @@
/**
* 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 ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('app'));

View File

@ -1,21 +0,0 @@
/**
* 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.
*/
export default function commonReducer(state = {}) {
return state;
}

View File

@ -1,21 +0,0 @@
/**
* 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.
*/
export default function localStorageUsageReducer(state = 0) {
return state;
}

View File

@ -39,7 +39,6 @@ import { addDangerToast } from 'src/components/MessageToasts/actions';
import { logEvent } from 'src/logger/actions'; import { logEvent } from 'src/logger/actions';
import { Logger, LOG_ACTIONS_LOAD_CHART } from 'src/logger/LogUtils'; import { Logger, LOG_ACTIONS_LOAD_CHART } from 'src/logger/LogUtils';
import { getClientErrorObject } from 'src/utils/getClientErrorObject'; import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import { safeStringify } from 'src/utils/safeStringify';
import { allowCrossDomain as domainShardingEnabled } from 'src/utils/hostNamesConfig'; import { allowCrossDomain as domainShardingEnabled } from 'src/utils/hostNamesConfig';
import { updateDataMask } from 'src/dataMask/actions'; import { updateDataMask } from 'src/dataMask/actions';
import { waitForAsyncData } from 'src/middleware/asyncEvent'; import { waitForAsyncData } from 'src/middleware/asyncEvent';
@ -571,17 +570,20 @@ export function postChartFormData(
); );
} }
export function redirectSQLLab(formData) { export function redirectSQLLab(formData, history) {
return dispatch => { return dispatch => {
getChartDataRequest({ formData, resultFormat: 'json', resultType: 'query' }) getChartDataRequest({ formData, resultFormat: 'json', resultType: 'query' })
.then(({ json }) => { .then(({ json }) => {
const redirectUrl = '/superset/sqllab/'; const redirectUrl = '/sqllab/';
const payload = { const payload = {
datasourceKey: formData.datasource, datasourceKey: formData.datasource,
sql: json.result[0].query, sql: json.result[0].query,
}; };
SupersetClient.postForm(redirectUrl, { history.push({
form_data: safeStringify(payload), pathname: redirectUrl,
state: {
requestedQuery: payload,
},
}); });
}) })
.catch(() => .catch(() =>

View File

@ -17,6 +17,7 @@
* under the License. * under the License.
*/ */
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
@ -151,12 +152,22 @@ export const ExploreChartHeader = ({
[dispatch], [dispatch],
); );
const history = useHistory();
const { redirectSQLLab } = actions;
const redirectToSQLLab = useCallback(
formData => {
redirectSQLLab(formData, history);
},
[redirectSQLLab, history],
);
const [menu, isDropdownVisible, setIsDropdownVisible] = const [menu, isDropdownVisible, setIsDropdownVisible] =
useExploreAdditionalActionsMenu( useExploreAdditionalActionsMenu(
latestQueryFormData, latestQueryFormData,
canDownload, canDownload,
slice, slice,
actions.redirectSQLLab, redirectToSQLLab,
openPropertiesModal, openPropertiesModal,
ownState, ownState,
metadata?.dashboards, metadata?.dashboards,

View File

@ -18,6 +18,7 @@
*/ */
import React from 'react'; import React from 'react';
import { Route } from 'react-router-dom';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { DatasourceType, JsonObject, SupersetClient } from '@superset-ui/core'; import { DatasourceType, JsonObject, SupersetClient } from '@superset-ui/core';
@ -27,15 +28,7 @@ import DatasourceControl from '.';
const SupersetClientGet = jest.spyOn(SupersetClient, 'get'); const SupersetClientGet = jest.spyOn(SupersetClient, 'get');
const createProps = (overrides: JsonObject = {}) => ({ const mockDatasource = {
hovered: false,
type: 'DatasourceControl',
label: 'Datasource',
default: null,
description: null,
value: '25__table',
form_data: {},
datasource: {
id: 25, id: 25,
database: { database: {
name: 'examples', name: 'examples',
@ -44,7 +37,17 @@ const createProps = (overrides: JsonObject = {}) => ({
type: 'table', type: 'table',
columns: [], columns: [],
owners: [{ first_name: 'john', last_name: 'doe', id: 1, username: 'jd' }], owners: [{ first_name: 'john', last_name: 'doe', id: 1, username: 'jd' }],
}, sql: 'SELECT * FROM mock_datasource_sql',
};
const createProps = (overrides: JsonObject = {}) => ({
hovered: false,
type: 'DatasourceControl',
label: 'Datasource',
default: null,
description: null,
value: '25__table',
form_data: {},
datasource: mockDatasource,
validationErrors: [], validationErrors: [],
name: 'datasource', name: 'datasource',
actions: { actions: {
@ -91,20 +94,20 @@ async function openAndSaveChanges(datasource: any) {
test('Should render', async () => { test('Should render', async () => {
const props = createProps(); const props = createProps();
render(<DatasourceControl {...props} />); render(<DatasourceControl {...props} />, { useRouter: true });
expect(await screen.findByTestId('datasource-control')).toBeVisible(); expect(await screen.findByTestId('datasource-control')).toBeVisible();
}); });
test('Should have elements', async () => { test('Should have elements', async () => {
const props = createProps(); const props = createProps();
render(<DatasourceControl {...props} />); render(<DatasourceControl {...props} />, { useRouter: true });
expect(await screen.findByText('channels')).toBeVisible(); expect(await screen.findByText('channels')).toBeVisible();
expect(screen.getByTestId('datasource-menu-trigger')).toBeVisible(); expect(screen.getByTestId('datasource-menu-trigger')).toBeVisible();
}); });
test('Should open a menu', async () => { test('Should open a menu', async () => {
const props = createProps(); const props = createProps();
render(<DatasourceControl {...props} />); render(<DatasourceControl {...props} />, { useRouter: true });
expect(screen.queryByText('Edit dataset')).not.toBeInTheDocument(); expect(screen.queryByText('Edit dataset')).not.toBeInTheDocument();
expect(screen.queryByText('Swap dataset')).not.toBeInTheDocument(); expect(screen.queryByText('Swap dataset')).not.toBeInTheDocument();
@ -131,7 +134,7 @@ test('Should not show SQL Lab for non sql_lab role', async () => {
username: 'gamma', username: 'gamma',
}, },
}); });
render(<DatasourceControl {...props} />); render(<DatasourceControl {...props} />, { useRouter: true });
userEvent.click(screen.getByTestId('datasource-menu-trigger')); userEvent.click(screen.getByTestId('datasource-menu-trigger'));
@ -154,7 +157,7 @@ test('Should show SQL Lab for sql_lab role', async () => {
username: 'sql', username: 'sql',
}, },
}); });
render(<DatasourceControl {...props} />); render(<DatasourceControl {...props} />, { useRouter: true });
userEvent.click(screen.getByTestId('datasource-menu-trigger')); userEvent.click(screen.getByTestId('datasource-menu-trigger'));
@ -178,6 +181,7 @@ test('Click on Swap dataset option', async () => {
render(<DatasourceControl {...props} />, { render(<DatasourceControl {...props} />, {
useRedux: true, useRedux: true,
useRouter: true,
}); });
userEvent.click(screen.getByTestId('datasource-menu-trigger')); userEvent.click(screen.getByTestId('datasource-menu-trigger'));
@ -198,6 +202,7 @@ test('Click on Edit dataset', async () => {
); );
render(<DatasourceControl {...props} />, { render(<DatasourceControl {...props} />, {
useRedux: true, useRedux: true,
useRouter: true,
}); });
userEvent.click(screen.getByTestId('datasource-menu-trigger')); userEvent.click(screen.getByTestId('datasource-menu-trigger'));
@ -223,6 +228,7 @@ test('Edit dataset should be disabled when user is not admin', async () => {
render(<DatasourceControl {...props} />, { render(<DatasourceControl {...props} />, {
useRedux: true, useRedux: true,
useRouter: true,
}); });
userEvent.click(screen.getByTestId('datasource-menu-trigger')); userEvent.click(screen.getByTestId('datasource-menu-trigger'));
@ -235,21 +241,41 @@ test('Edit dataset should be disabled when user is not admin', async () => {
test('Click on View in SQL Lab', async () => { test('Click on View in SQL Lab', async () => {
const props = createProps(); const props = createProps();
const postFormSpy = jest.spyOn(SupersetClient, 'postForm');
postFormSpy.mockImplementation(jest.fn());
render(<DatasourceControl {...props} />, { const { queryByTestId, getByTestId } = render(
<>
<Route
path="/sqllab"
render={({ location }) => (
<div data-test="mock-sqllab-route">
{JSON.stringify(location.state)}
</div>
)}
/>
<DatasourceControl {...props} />
</>,
{
useRedux: true, useRedux: true,
}); useRouter: true,
},
);
userEvent.click(screen.getByTestId('datasource-menu-trigger')); userEvent.click(screen.getByTestId('datasource-menu-trigger'));
expect(postFormSpy).toBeCalledTimes(0); expect(queryByTestId('mock-sqllab-route')).not.toBeInTheDocument();
await act(async () => { await act(async () => {
userEvent.click(screen.getByText('View in SQL Lab')); userEvent.click(screen.getByText('View in SQL Lab'));
}); });
expect(postFormSpy).toBeCalledTimes(1); expect(getByTestId('mock-sqllab-route')).toBeInTheDocument();
expect(JSON.parse(`${getByTestId('mock-sqllab-route').textContent}`)).toEqual(
{
requestedQuery: {
datasourceKey: `${mockDatasource.id}__${mockDatasource.type}`,
sql: mockDatasource.sql,
},
},
);
}); });
test('Should open a different menu when datasource=query', async () => { test('Should open a different menu when datasource=query', async () => {
@ -261,7 +287,7 @@ test('Should open a different menu when datasource=query', async () => {
type: DatasourceType.Query, type: DatasourceType.Query,
}, },
}; };
render(<DatasourceControl {...queryProps} />); render(<DatasourceControl {...queryProps} />, { useRouter: true });
expect(screen.queryByText('Query preview')).not.toBeInTheDocument(); expect(screen.queryByText('Query preview')).not.toBeInTheDocument();
expect(screen.queryByText('View in SQL Lab')).not.toBeInTheDocument(); expect(screen.queryByText('View in SQL Lab')).not.toBeInTheDocument();
@ -284,7 +310,10 @@ test('Click on Save as dataset', async () => {
}, },
}; };
render(<DatasourceControl {...queryProps} />, { useRedux: true }); render(<DatasourceControl {...queryProps} />, {
useRedux: true,
useRouter: true,
});
userEvent.click(screen.getByTestId('datasource-menu-trigger')); userEvent.click(screen.getByTestId('datasource-menu-trigger'));
userEvent.click(screen.getByText('Save as dataset')); userEvent.click(screen.getByText('Save as dataset'));
@ -327,6 +356,7 @@ test('should set the default temporal column', async () => {
}; };
render(<DatasourceControl {...props} {...overrideProps} />, { render(<DatasourceControl {...props} {...overrideProps} />, {
useRedux: true, useRedux: true,
useRouter: true,
}); });
await openAndSaveChanges(overrideProps.datasource); await openAndSaveChanges(overrideProps.datasource);
@ -362,6 +392,7 @@ test('should set the first available temporal column', async () => {
}; };
render(<DatasourceControl {...props} {...overrideProps} />, { render(<DatasourceControl {...props} {...overrideProps} />, {
useRedux: true, useRedux: true,
useRouter: true,
}); });
await openAndSaveChanges(overrideProps.datasource); await openAndSaveChanges(overrideProps.datasource);
@ -397,6 +428,7 @@ test('should not set the temporal column', async () => {
}; };
render(<DatasourceControl {...props} {...overrideProps} />, { render(<DatasourceControl {...props} {...overrideProps} />, {
useRedux: true, useRedux: true,
useRouter: true,
}); });
await openAndSaveChanges(overrideProps.datasource); await openAndSaveChanges(overrideProps.datasource);
@ -410,7 +442,7 @@ test('should not set the temporal column', async () => {
test('should show missing params state', () => { test('should show missing params state', () => {
const props = createProps({ datasource: fallbackExploreInitialData.dataset }); const props = createProps({ datasource: fallbackExploreInitialData.dataset });
render(<DatasourceControl {...props} />, { useRedux: true }); render(<DatasourceControl {...props} />, { useRedux: true, useRouter: true });
expect(screen.getByText(/missing dataset/i)).toBeVisible(); expect(screen.getByText(/missing dataset/i)).toBeVisible();
expect(screen.getByText(/missing url parameters/i)).toBeVisible(); expect(screen.getByText(/missing url parameters/i)).toBeVisible();
expect( expect(
@ -426,7 +458,7 @@ test('should show missing dataset state', () => {
// @ts-ignore // @ts-ignore
window.location = { search: '?slice_id=152' }; window.location = { search: '?slice_id=152' };
const props = createProps({ datasource: fallbackExploreInitialData.dataset }); const props = createProps({ datasource: fallbackExploreInitialData.dataset });
render(<DatasourceControl {...props} />, { useRedux: true }); render(<DatasourceControl {...props} />, { useRedux: true, useRouter: true });
expect(screen.getAllByText(/missing dataset/i)).toHaveLength(2); expect(screen.getAllByText(/missing dataset/i)).toHaveLength(2);
expect( expect(
screen.getByText( screen.getByText(

View File

@ -20,13 +20,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { import { DatasourceType, styled, t, withTheme } from '@superset-ui/core';
DatasourceType,
SupersetClient,
styled,
t,
withTheme,
} from '@superset-ui/core';
import { getTemporalColumns } from '@superset-ui/chart-controls'; import { getTemporalColumns } from '@superset-ui/chart-controls';
import { getUrlParam } from 'src/utils/urlUtils'; import { getUrlParam } from 'src/utils/urlUtils';
import { AntdDropdown } from 'src/components'; import { AntdDropdown } from 'src/components';
@ -50,8 +44,8 @@ import ModalTrigger from 'src/components/ModalTrigger';
import ViewQueryModalFooter from 'src/explore/components/controls/ViewQueryModalFooter'; import ViewQueryModalFooter from 'src/explore/components/controls/ViewQueryModalFooter';
import ViewQuery from 'src/explore/components/controls/ViewQuery'; import ViewQuery from 'src/explore/components/controls/ViewQuery';
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal'; import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
import { safeStringify } from 'src/utils/safeStringify';
import { isString } from 'lodash'; import { isString } from 'lodash';
import { Link } from 'react-router-dom';
const propTypes = { const propTypes = {
actions: PropTypes.object.isRequired, actions: PropTypes.object.isRequired,
@ -126,7 +120,6 @@ const Styles = styled.div`
`; `;
const CHANGE_DATASET = 'change_dataset'; const CHANGE_DATASET = 'change_dataset';
const VIEW_IN_SQL_LAB = 'view_in_sql_lab';
const EDIT_DATASET = 'edit_dataset'; const EDIT_DATASET = 'edit_dataset';
const QUERY_PREVIEW = 'query_preview'; const QUERY_PREVIEW = 'query_preview';
const SAVE_AS_DATASET = 'save_as_dataset'; const SAVE_AS_DATASET = 'save_as_dataset';
@ -238,19 +231,6 @@ class DatasourceControl extends React.PureComponent {
this.toggleEditDatasourceModal(); this.toggleEditDatasourceModal();
break; break;
case VIEW_IN_SQL_LAB:
{
const { datasource } = this.props;
const payload = {
datasourceKey: `${datasource.id}__${datasource.type}`,
sql: datasource.sql,
};
SupersetClient.postForm('/superset/sqllab/', {
form_data: safeStringify(payload),
});
}
break;
case SAVE_AS_DATASET: case SAVE_AS_DATASET:
this.toggleSaveDatasetModal(); this.toggleSaveDatasetModal();
break; break;
@ -286,6 +266,10 @@ class DatasourceControl extends React.PureComponent {
const canAccessSqlLab = userHasPermission(user, 'SQL Lab', 'menu_access'); const canAccessSqlLab = userHasPermission(user, 'SQL Lab', 'menu_access');
const editText = t('Edit dataset'); const editText = t('Edit dataset');
const requestedQuery = {
datasourceKey: `${datasource.id}__${datasource.type}`,
sql: datasource.sql,
};
const defaultDatasourceMenu = ( const defaultDatasourceMenu = (
<Menu onClick={this.handleMenuItemClick}> <Menu onClick={this.handleMenuItemClick}>
@ -310,7 +294,16 @@ class DatasourceControl extends React.PureComponent {
)} )}
<Menu.Item key={CHANGE_DATASET}>{t('Swap dataset')}</Menu.Item> <Menu.Item key={CHANGE_DATASET}>{t('Swap dataset')}</Menu.Item>
{!isMissingDatasource && canAccessSqlLab && ( {!isMissingDatasource && canAccessSqlLab && (
<Menu.Item key={VIEW_IN_SQL_LAB}>{t('View in SQL Lab')}</Menu.Item> <Menu.Item>
<Link
to={{
pathname: '/sqllab',
state: { requestedQuery },
}}
>
{t('View in SQL Lab')}
</Link>
</Menu.Item>
)} )}
</Menu> </Menu>
); );
@ -340,7 +333,16 @@ class DatasourceControl extends React.PureComponent {
/> />
</Menu.Item> </Menu.Item>
{canAccessSqlLab && ( {canAccessSqlLab && (
<Menu.Item key={VIEW_IN_SQL_LAB}>{t('View in SQL Lab')}</Menu.Item> <Menu.Item>
<Link
to={{
pathname: '/sqllab',
state: { requestedQuery },
}}
>
{t('View in SQL Lab')}
</Link>
</Menu.Item>
)} )}
<Menu.Item key={SAVE_AS_DATASET}>{t('Save as dataset')}</Menu.Item> <Menu.Item key={SAVE_AS_DATASET}>{t('Save as dataset')}</Menu.Item>
</Menu> </Menu>

View File

@ -18,8 +18,9 @@
*/ */
import React from 'react'; import React from 'react';
import { isObject } from 'lodash'; import { isObject } from 'lodash';
import { t, SupersetClient } from '@superset-ui/core'; import { t } from '@superset-ui/core';
import Button from 'src/components/Button'; import Button from 'src/components/Button';
import { useHistory } from 'react-router-dom';
interface SimpleDataSource { interface SimpleDataSource {
id: string; id: string;
@ -42,12 +43,18 @@ const ViewQueryModalFooter: React.FC<ViewQueryModalFooterProps> = (props: {
changeDatasource: () => void; changeDatasource: () => void;
datasource: SimpleDataSource; datasource: SimpleDataSource;
}) => { }) => {
const history = useHistory();
const viewInSQLLab = (id: string, type: string, sql: string) => { const viewInSQLLab = (id: string, type: string, sql: string) => {
const payload = { const payload = {
datasourceKey: `${id}__${type}`, datasourceKey: `${id}__${type}`,
sql, sql,
}; };
SupersetClient.postForm('/superset/sqllab/', payload); history.push({
pathname: '/sqllab',
state: {
requestedQuery: payload,
},
});
}; };
const openSQL = () => { const openSQL = () => {

View File

@ -32,6 +32,7 @@ import React, {
useReducer, useReducer,
Reducer, Reducer,
} from 'react'; } from 'react';
import { useHistory } from 'react-router-dom';
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers'; import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
import { UploadChangeParam, UploadFile } from 'antd/lib/upload/interface'; import { UploadChangeParam, UploadFile } from 'antd/lib/upload/interface';
import Tabs from 'src/components/Tabs'; import Tabs from 'src/components/Tabs';
@ -141,7 +142,6 @@ interface DatabaseModalProps {
show: boolean; show: boolean;
databaseId: number | undefined; // If included, will go into edit mode databaseId: number | undefined; // If included, will go into edit mode
dbEngine: string | undefined; // if included goto step 2 with engine already set dbEngine: string | undefined; // if included goto step 2 with engine already set
history?: any;
} }
export enum ActionType { export enum ActionType {
@ -526,7 +526,6 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
show, show,
databaseId, databaseId,
dbEngine, dbEngine,
history,
}) => { }) => {
const [db, setDB] = useReducer< const [db, setDB] = useReducer<
Reducer<Partial<DatabaseObject> | null, DBReducerActionType> Reducer<Partial<DatabaseObject> | null, DBReducerActionType>
@ -627,6 +626,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
(DB: DatabaseObject) => DB.backend === engine || DB.engine === engine, (DB: DatabaseObject) => DB.backend === engine || DB.engine === engine,
)?.parameters !== undefined; )?.parameters !== undefined;
const showDBError = validationErrors || dbErrors; const showDBError = validationErrors || dbErrors;
const history = useHistory();
const dbModel: DatabaseForm = const dbModel: DatabaseForm =
availableDbs?.databases?.find( availableDbs?.databases?.find(
@ -700,13 +700,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
}; };
const redirectURL = (url: string) => { const redirectURL = (url: string) => {
/* TODO (lyndsiWilliams): This check and passing history history.push(url);
as a prop can be removed once SQL Lab is in the SPA */
if (!isEmpty(history)) {
history?.push(url);
} else {
window.location.href = url;
}
}; };
// Database import logic // Database import logic
@ -1583,7 +1577,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
onClick={() => { onClick={() => {
setLoading(true); setLoading(true);
fetchAndSetDB(); fetchAndSetDB();
redirectURL(`/superset/sqllab/?db=true`); redirectURL(`/sqllab?db=true`);
}} }}
> >
{t('QUERY DATA IN SQL LAB')} {t('QUERY DATA IN SQL LAB')}

View File

@ -45,7 +45,9 @@ jest.mock(
describe('DatasetPanel', () => { describe('DatasetPanel', () => {
test('renders a blank state DatasetPanel', () => { test('renders a blank state DatasetPanel', () => {
render(<DatasetPanel hasError={false} columnList={[]} loading={false} />); render(<DatasetPanel hasError={false} columnList={[]} loading={false} />, {
useRouter: true,
});
const blankDatasetImg = screen.getByRole('img', { name: /empty/i }); const blankDatasetImg = screen.getByRole('img', { name: /empty/i });
expect(blankDatasetImg).toBeVisible(); expect(blankDatasetImg).toBeVisible();
@ -73,6 +75,9 @@ describe('DatasetPanel', () => {
columnList={[]} columnList={[]}
loading={false} loading={false}
/>, />,
{
useRouter: true,
},
); );
const blankDatasetImg = screen.getByRole('img', { name: /empty/i }); const blankDatasetImg = screen.getByRole('img', { name: /empty/i });
@ -91,6 +96,9 @@ describe('DatasetPanel', () => {
columnList={[]} columnList={[]}
loading loading
/>, />,
{
useRouter: true,
},
); );
const blankDatasetImg = screen.getByAltText(ALT_LOADING); const blankDatasetImg = screen.getByAltText(ALT_LOADING);
@ -107,6 +115,9 @@ describe('DatasetPanel', () => {
columnList={[]} columnList={[]}
loading={false} loading={false}
/>, />,
{
useRouter: true,
},
); );
const errorTitle = screen.getByText(ERROR_TITLE); const errorTitle = screen.getByText(ERROR_TITLE);
@ -124,6 +135,9 @@ describe('DatasetPanel', () => {
columnList={exampleColumns} columnList={exampleColumns}
loading={false} loading={false}
/>, />,
{
useRouter: true,
},
); );
expect(await screen.findByText(tableName)).toBeVisible(); expect(await screen.findByText(tableName)).toBeVisible();
expect(screen.getByText(COLUMN_TITLE)).toBeVisible(); expect(screen.getByText(COLUMN_TITLE)).toBeVisible();
@ -148,6 +162,9 @@ describe('DatasetPanel', () => {
loading={false} loading={false}
datasets={exampleDataset} datasets={exampleDataset}
/>, />,
{
useRouter: true,
},
); );
// This is text in the info banner // This is text in the info banner

View File

@ -20,6 +20,7 @@
import React from 'react'; import React from 'react';
import { t, styled } from '@superset-ui/core'; import { t, styled } from '@superset-ui/core';
import { EmptyStateBig } from 'src/components/EmptyState'; import { EmptyStateBig } from 'src/components/EmptyState';
import { Link } from 'react-router-dom';
const StyledContainer = styled.div` const StyledContainer = styled.div`
padding: ${({ theme }) => theme.gridUnit * 8}px padding: ${({ theme }) => theme.gridUnit * 8}px
@ -50,15 +51,11 @@ export const VIEW_DATASET_MESSAGE = t(
const renderEmptyDescription = () => ( const renderEmptyDescription = () => (
<> <>
{SELECT_MESSAGE} {SELECT_MESSAGE}
<span <Link to="/sqllab">
role="button" <span role="button" tabIndex={0}>
onClick={() => {
window.location.href = `/superset/sqllab`;
}}
tabIndex={0}
>
{CREATE_MESSAGE} {CREATE_MESSAGE}
</span> </span>
</Link>
{VIEW_DATASET_MESSAGE} {VIEW_DATASET_MESSAGE}
</> </>
); );

View File

@ -35,7 +35,7 @@ jest.mock('react-router-dom', () => ({
describe('DatasetLayout', () => { describe('DatasetLayout', () => {
it('renders nothing when no components are passed in', () => { it('renders nothing when no components are passed in', () => {
render(<DatasetLayout />); render(<DatasetLayout />, { useRouter: true });
const layoutWrapper = screen.getByTestId('dataset-layout-wrapper'); const layoutWrapper = screen.getByTestId('dataset-layout-wrapper');
expect(layoutWrapper).toHaveTextContent(''); expect(layoutWrapper).toHaveTextContent('');
@ -55,7 +55,7 @@ describe('DatasetLayout', () => {
it('renders a LeftPanel when passed in', async () => { it('renders a LeftPanel when passed in', async () => {
render( render(
<DatasetLayout leftPanel={<LeftPanel setDataset={() => null} />} />, <DatasetLayout leftPanel={<LeftPanel setDataset={() => null} />} />,
{ useRedux: true }, { useRedux: true, useRouter: true },
); );
expect( expect(
@ -65,7 +65,9 @@ describe('DatasetLayout', () => {
}); });
it('renders a DatasetPanel when passed in', () => { it('renders a DatasetPanel when passed in', () => {
render(<DatasetLayout datasetPanel={<DatasetPanel />} />); render(<DatasetLayout datasetPanel={<DatasetPanel />} />, {
useRouter: true,
});
const blankDatasetImg = screen.getByRole('img', { name: /empty/i }); const blankDatasetImg = screen.getByRole('img', { name: /empty/i });
const blankDatasetTitle = screen.getByText(/select dataset source/i); const blankDatasetTitle = screen.getByText(/select dataset source/i);
@ -75,13 +77,16 @@ describe('DatasetLayout', () => {
}); });
it('renders a RightPanel when passed in', () => { it('renders a RightPanel when passed in', () => {
render(<DatasetLayout rightPanel={RightPanel()} />); render(<DatasetLayout rightPanel={RightPanel()} />, { useRouter: true });
expect(screen.getByText(/right panel/i)).toBeVisible(); expect(screen.getByText(/right panel/i)).toBeVisible();
}); });
it('renders a Footer when passed in', () => { it('renders a Footer when passed in', () => {
render(<DatasetLayout footer={<Footer url="" />} />, { useRedux: true }); render(<DatasetLayout footer={<Footer url="" />} />, {
useRedux: true,
useRouter: true,
});
expect(screen.getByText(/Cancel/i)).toBeVisible(); expect(screen.getByText(/Cancel/i)).toBeVisible();
}); });

View File

@ -105,7 +105,7 @@ const getEntityIcon = (entity: ActivityObject) => {
}; };
const getEntityUrl = (entity: ActivityObject) => { const getEntityUrl = (entity: ActivityObject) => {
if ('sql' in entity) return `/superset/sqllab?savedQueryId=${entity.id}`; if ('sql' in entity) return `/sqllab?savedQueryId=${entity.id}`;
if ('url' in entity) return entity.url; if ('url' in entity) return entity.url;
return entity.item_url; return entity.item_url;
}; };

View File

@ -17,6 +17,7 @@
* under the License. * under the License.
*/ */
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom';
import Button from 'src/components/Button'; import Button from 'src/components/Button';
import { Empty } from 'src/components'; import { Empty } from 'src/components';
import { TableTab } from 'src/views/CRUD/types'; import { TableTab } from 'src/views/CRUD/types';
@ -81,7 +82,7 @@ export default function EmptyState({
const mineRedirects: Redirects = { const mineRedirects: Redirects = {
[WelcomeTable.Charts]: '/chart/add', [WelcomeTable.Charts]: '/chart/add',
[WelcomeTable.Dashboards]: '/dashboard/new', [WelcomeTable.Dashboards]: '/dashboard/new',
[WelcomeTable.SavedQueries]: '/superset/sqllab?new=true', [WelcomeTable.SavedQueries]: '/sqllab?new=true',
}; };
const favRedirects: Redirects = { const favRedirects: Redirects = {
[WelcomeTable.Charts]: '/chart/list', [WelcomeTable.Charts]: '/chart/list',
@ -140,12 +141,8 @@ export default function EmptyState({
> >
{tableName !== WelcomeTable.Recents && ( {tableName !== WelcomeTable.Recents && (
<ButtonContainer> <ButtonContainer>
<Button <Link to={mineRedirects[tableName]}>
buttonStyle="primary" <Button buttonStyle="primary">
onClick={() => {
window.location.href = mineRedirects[tableName];
}}
>
<i className="fa fa-plus" /> <i className="fa fa-plus" />
{tableName === WelcomeTable.SavedQueries {tableName === WelcomeTable.SavedQueries
? t('SQL query') ? t('SQL query')
@ -154,6 +151,7 @@ export default function EmptyState({
.slice(0, tableName.length - 1) .slice(0, tableName.length - 1)
.join('')} .join('')}
</Button> </Button>
</Link>
</ButtonContainer> </ButtonContainer>
)} )}
</Empty> </Empty>

View File

@ -62,7 +62,7 @@ const dropdownItems = [
}, },
{ {
label: 'SQL query', label: 'SQL query',
url: '/superset/sqllab?new=true', url: '/sqllab?new=true',
icon: 'fa-fw fa-search', icon: 'fa-fw fa-search',
perm: 'can_sqllab', perm: 'can_sqllab',
view: 'Superset', view: 'Superset',

View File

@ -73,7 +73,7 @@ const dropdownItems = [
}, },
{ {
label: 'SQL query', label: 'SQL query',
url: '/superset/sqllab?new=true', url: '/sqllab?new=true',
icon: 'fa-fw fa-search', icon: 'fa-fw fa-search',
perm: 'can_sqllab', perm: 'can_sqllab',
view: 'Superset', view: 'Superset',

View File

@ -210,7 +210,7 @@ const RightMenu = ({
}, },
{ {
label: t('SQL query'), label: t('SQL query'),
url: '/superset/sqllab?new=true', url: '/sqllab?new=true',
icon: 'fa-fw fa-search', icon: 'fa-fw fa-search',
perm: 'can_sqllab', perm: 'can_sqllab',
view: 'Superset', view: 'Superset',

View File

@ -17,6 +17,7 @@
* under the License. * under the License.
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { styled, SupersetClient, t, useTheme } from '@superset-ui/core'; import { styled, SupersetClient, t, useTheme } from '@superset-ui/core';
import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light'; import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light';
import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql'; import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql';
@ -193,12 +194,8 @@ const SavedQueries = ({
const renderMenu = (query: Query) => ( const renderMenu = (query: Query) => (
<Menu> <Menu>
{canEdit && ( {canEdit && (
<Menu.Item <Menu.Item>
onClick={() => { <Link to={`/sqllab?savedQueryId=${query.id}`}>{t('Edit')}</Link>
window.location.href = `/superset/sqllab?savedQueryId=${query.id}`;
}}
>
{t('Edit')}
</Menu.Item> </Menu.Item>
)} )}
<Menu.Item <Menu.Item
@ -256,15 +253,12 @@ const SavedQueries = ({
buttons={[ buttons={[
{ {
name: ( name: (
<> <Link to="/sqllab?new=true">
<i className="fa fa-plus" /> <i className="fa fa-plus" />
{t('SQL Query')} {t('SQL Query')}
</> </Link>
), ),
buttonStyle: 'tertiary', buttonStyle: 'tertiary',
onClick: () => {
window.location.href = '/superset/sqllab?new=true';
},
}, },
{ {
name: t('View All »'), name: t('View All »'),
@ -278,15 +272,10 @@ const SavedQueries = ({
{queries.length > 0 ? ( {queries.length > 0 ? (
<CardContainer showThumbnails={showThumbnails}> <CardContainer showThumbnails={showThumbnails}>
{queries.map(q => ( {queries.map(q => (
<CardStyles <CardStyles key={q.id}>
onClick={() => {
window.location.href = `/superset/sqllab?savedQueryId=${q.id}`;
}}
key={q.id}
>
<ListViewCard <ListViewCard
imgURL="" imgURL=""
url={`/superset/sqllab?savedQueryId=${q.id}`} url={`/sqllab?savedQueryId=${q.id}`}
title={q.label} title={q.label}
imgFallbackURL="/static/assets/images/empty-query.svg" imgFallbackURL="/static/assets/images/empty-query.svg"
description={t('Ran %s', q.changed_on_delta_humanized)} description={t('Ran %s', q.changed_on_delta_humanized)}

View File

@ -180,7 +180,7 @@ type MenuChild = {
export interface ButtonProps { export interface ButtonProps {
name: ReactNode; name: ReactNode;
onClick: OnClickHandler; onClick?: OnClickHandler;
'data-test'?: string; 'data-test'?: string;
buttonStyle: buttonStyle:
| 'primary' | 'primary'

View File

@ -30,7 +30,7 @@ export const commonMenuData = {
{ {
name: 'Query history', name: 'Query history',
label: t('Query history'), label: t('Query history'),
url: '/superset/sqllab/history/', url: '/sqllab/history/',
usesRouter: true, usesRouter: true,
}, },
], ],

View File

@ -17,7 +17,10 @@
* under the License. * under the License.
*/ */
import rison from 'rison'; import rison from 'rison';
import { getClientErrorObject } from 'src/utils/getClientErrorObject'; import {
ClientErrorObject,
getClientErrorObject,
} from 'src/utils/getClientErrorObject';
import { createApi, BaseQueryFn } from '@reduxjs/toolkit/query/react'; import { createApi, BaseQueryFn } from '@reduxjs/toolkit/query/react';
import { import {
SupersetClient, SupersetClient,
@ -35,7 +38,9 @@ export const supersetClientQuery: BaseQueryFn<
parseMethod?: ParseMethod; parseMethod?: ParseMethod;
transformResponse?: (response: SupersetClientResponse) => JsonValue; transformResponse?: (response: SupersetClientResponse) => JsonValue;
urlParams?: Record<string, number | string | undefined | boolean>; urlParams?: Record<string, number | string | undefined | boolean>;
} },
JsonValue,
ClientErrorObject
> = ( > = (
{ {
endpoint, endpoint,

View File

@ -31,7 +31,7 @@ jest.mock('react-router-dom', () => ({
describe('AddDataset', () => { describe('AddDataset', () => {
it('renders a blank state AddDataset', async () => { it('renders a blank state AddDataset', async () => {
render(<AddDataset />, { useRedux: true }); render(<AddDataset />, { useRedux: true, useRouter: true });
const blankeStateImgs = screen.getAllByRole('img', { name: /empty/i }); const blankeStateImgs = screen.getAllByRole('img', { name: /empty/i });

View File

@ -17,6 +17,7 @@
* under the License. * under the License.
*/ */
import React, { useMemo, useState, useCallback, ReactElement } from 'react'; import React, { useMemo, useState, useCallback, ReactElement } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { import {
QueryState, QueryState,
styled, styled,
@ -102,6 +103,7 @@ function QueryList({ addDangerToast }: QueryListProps) {
useState<QueryObject>(); useState<QueryObject>();
const theme = useTheme(); const theme = useTheme();
const history = useHistory();
const handleQueryPreview = useCallback( const handleQueryPreview = useCallback(
(id: number) => { (id: number) => {
@ -334,9 +336,9 @@ function QueryList({ addDangerToast }: QueryListProps) {
}, },
}: any) => ( }: any) => (
<Tooltip title={t('Open query in SQL Lab')} placement="bottom"> <Tooltip title={t('Open query in SQL Lab')} placement="bottom">
<a href={`/superset/sqllab?queryId=${id}`}> <Link to={`/sqllab?queryId=${id}`}>
<Icons.Full iconColor={theme.colors.grayscale.base} /> <Icons.Full iconColor={theme.colors.grayscale.base} />
</a> </Link>
</Tooltip> </Tooltip>
), ),
}, },
@ -427,9 +429,7 @@ function QueryList({ addDangerToast }: QueryListProps) {
query={queryCurrentlyPreviewing} query={queryCurrentlyPreviewing}
queries={queries} queries={queries}
fetchData={handleQueryPreview} fetchData={handleQueryPreview}
openInSqlLab={(id: number) => openInSqlLab={(id: number) => history.push(`/sqllab?queryId=${id}`)}
window.location.assign(`/superset/sqllab?queryId=${id}`)
}
show show
/> />
)} )}

View File

@ -25,6 +25,7 @@ import {
t, t,
} from '@superset-ui/core'; } from '@superset-ui/core';
import React, { useState, useMemo, useCallback } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { Link, useHistory } from 'react-router-dom';
import rison from 'rison'; import rison from 'rison';
import moment from 'moment'; import moment from 'moment';
import { import {
@ -127,6 +128,7 @@ function SavedQueryList({
sshTunnelPrivateKeyPasswordFields, sshTunnelPrivateKeyPasswordFields,
setSSHTunnelPrivateKeyPasswordFields, setSSHTunnelPrivateKeyPasswordFields,
] = useState<string[]>([]); ] = useState<string[]>([]);
const history = useHistory();
const openSavedQueryImportModal = () => { const openSavedQueryImportModal = () => {
showImportModal(true); showImportModal(true);
@ -148,10 +150,6 @@ function SavedQueryList({
const canExport = const canExport =
hasPerm('can_export') && isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT); hasPerm('can_export') && isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT);
const openNewQuery = () => {
window.open(`${window.location.origin}/superset/sqllab?new=true`);
};
const handleSavedQueryPreview = useCallback( const handleSavedQueryPreview = useCallback(
(id: number) => { (id: number) => {
SupersetClient.get({ SupersetClient.get({
@ -187,11 +185,10 @@ function SavedQueryList({
subMenuButtons.push({ subMenuButtons.push({
name: ( name: (
<> <Link to="/sqllab?new=true">
<i className="fa fa-plus" /> {t('Query')} <i className="fa fa-plus" /> {t('Query')}
</> </Link>
), ),
onClick: openNewQuery,
buttonStyle: 'primary', buttonStyle: 'primary',
}); });
@ -217,15 +214,13 @@ function SavedQueryList({
// Action methods // Action methods
const openInSqlLab = (id: number) => { const openInSqlLab = (id: number) => {
window.open(`${window.location.origin}/superset/sqllab?savedQueryId=${id}`); history.push(`/sqllab?savedQueryId=${id}`);
}; };
const copyQueryLink = useCallback( const copyQueryLink = useCallback(
(id: number) => { (id: number) => {
copyTextToClipboard(() => copyTextToClipboard(() =>
Promise.resolve( Promise.resolve(`${window.location.origin}/sqllab?savedQueryId=${id}`),
`${window.location.origin}/superset/sqllab?savedQueryId=${id}`,
),
) )
.then(() => { .then(() => {
addSuccessToast(t('Link Copied!')); addSuccessToast(t('Link Copied!'));

View File

@ -16,17 +16,26 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { combineReducers } from 'redux';
import messageToasts from 'src/components/MessageToasts/reducers';
import sqlLab from './sqlLab';
import localStorageUsageInKilobytes from './localStorageUsage';
import common from './common';
export const reducers = { import React, { createContext, useContext } from 'react';
sqlLab, import { useLocation } from 'react-router-dom';
localStorageUsageInKilobytes,
messageToasts, export type LocationState = {
common, requestedQuery?: Record<string, any>;
}; };
export default combineReducers(reducers); export const locationContext = createContext<LocationState>({});
const { Provider } = locationContext;
const EMPTY_STATE: LocationState = {};
export const LocationProvider: React.FC = ({
children,
}: {
children: React.ReactNode;
}) => {
const location = useLocation<LocationState>();
return <Provider value={location.state || EMPTY_STATE}>{children}</Provider>;
};
export const useLocationState = () => useContext(locationContext);

View File

@ -0,0 +1,99 @@
/**
* 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 fetchMock from 'fetch-mock';
import React from 'react';
import { omit } from 'lodash';
import {
render,
act,
waitFor,
defaultStore as store,
createStore,
} from 'spec/helpers/testing-library';
import reducers from 'spec/helpers/reducerIndex';
import { api } from 'src/hooks/apiResources/queryApi';
import { DEFAULT_COMMON_BOOTSTRAP_DATA } from 'src/constants';
import getInitialState from 'src/SqlLab/reducers/getInitialState';
import SqlLab from '.';
const fakeApiResult = {
result: {
common: DEFAULT_COMMON_BOOTSTRAP_DATA,
tab_state_ids: [],
databases: [],
queries: {},
user: {
userId: 1,
username: 'some name',
isActive: true,
isAnonymous: false,
firstName: 'first name',
lastName: 'last name',
permissions: {},
roles: {},
},
},
};
const expectedResult = fakeApiResult.result;
const sqlLabInitialStateApiRoute = `glob:*/api/v1/sqllab/`;
afterEach(() => {
fetchMock.reset();
act(() => {
store.dispatch(api.util.resetApiState());
});
});
beforeEach(() => {
fetchMock.get(sqlLabInitialStateApiRoute, fakeApiResult);
});
jest.mock('src/SqlLab/components/App', () => () => (
<div data-test="mock-sqllab-app" />
));
test('is valid', () => {
expect(React.isValidElement(<SqlLab />)).toBe(true);
});
test('fetches initial data and renders', async () => {
expect(fetchMock.calls(sqlLabInitialStateApiRoute).length).toBe(0);
const storeWithSqlLab = createStore({}, reducers);
const { getByTestId } = render(<SqlLab />, {
useRedux: true,
useRouter: true,
store: storeWithSqlLab,
});
await waitFor(() =>
expect(fetchMock.calls(sqlLabInitialStateApiRoute).length).toBe(1),
);
expect(getByTestId('mock-sqllab-app')).toBeInTheDocument();
const { sqlLab } = getInitialState(expectedResult);
expect(storeWithSqlLab.getState()).toEqual(
expect.objectContaining({
sqlLab: expect.objectContaining(
omit(sqlLab, ['queriesLastUpdate', 'editorTabLastUpdatedAt']),
),
}),
);
});

View File

@ -0,0 +1,78 @@
/**
* 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, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { css } from '@superset-ui/core';
import { useSqlLabInitialState } from 'src/hooks/apiResources/sqlLab';
import type { InitialState } from 'src/hooks/apiResources/sqlLab';
import { resetState } from 'src/SqlLab/actions/sqlLab';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import type { SqlLabRootState } from 'src/SqlLab/types';
import { SqlLabGlobalStyles } from 'src/SqlLab//SqlLabGlobalStyles';
import App from 'src/SqlLab/components/App';
import Loading from 'src/components/Loading';
import useEffectEvent from 'src/hooks/useEffectEvent';
import { LocationProvider } from './LocationContext';
export default function SqlLab() {
const editorTabLastUpdatedAt = useSelector<SqlLabRootState, number>(
state => state.sqlLab.editorTabLastUpdatedAt || 0,
);
const { data, isLoading, isError, error, fulfilledTimeStamp } =
useSqlLabInitialState();
const shouldInitialize = editorTabLastUpdatedAt <= (fulfilledTimeStamp || 0);
const dispatch = useDispatch();
const initBootstrapData = useEffectEvent(
(sqlLabInitialState: InitialState) => {
if (shouldInitialize) {
dispatch(resetState(sqlLabInitialState));
}
},
);
useEffect(() => {
if (data) {
initBootstrapData(data);
}
}, [data, initBootstrapData]);
if (isLoading || shouldInitialize) return <Loading />;
if (isError && error?.message) {
dispatch(addDangerToast(error?.message));
return null;
}
return (
<LocationProvider>
<div
css={css`
flex: 1 1 auto;
position: relative;
display: flex;
flex-direction: column;
`}
>
<SqlLabGlobalStyles />
<App />
</div>
</LocationProvider>
);
}

View File

@ -674,9 +674,7 @@ export const copyQueryLink = (
addSuccessToast: (arg0: string) => void, addSuccessToast: (arg0: string) => void,
) => { ) => {
copyTextToClipboard(() => copyTextToClipboard(() =>
Promise.resolve( Promise.resolve(`${window.location.origin}/sqllab?savedQueryId=${id}`),
`${window.location.origin}/superset/sqllab?savedQueryId=${id}`,
),
) )
.then(() => { .then(() => {
addSuccessToast(t('Link Copied!')); addSuccessToast(t('Link Copied!'));

View File

@ -104,6 +104,10 @@ const SavedQueryList = lazy(
import(/* webpackChunkName: "SavedQueryList" */ 'src/pages/SavedQueryList'), import(/* webpackChunkName: "SavedQueryList" */ 'src/pages/SavedQueryList'),
); );
const SqlLab = lazy(
() => import(/* webpackChunkName: "SqlLab" */ 'src/pages/SqlLab'),
);
const AllEntities = lazy( const AllEntities = lazy(
() => import(/* webpackChunkName: "AllEntities" */ 'src/pages/AllEntities'), () => import(/* webpackChunkName: "AllEntities" */ 'src/pages/AllEntities'),
); );
@ -176,7 +180,7 @@ export const routes: Routes = [
Component: AnnotationList, Component: AnnotationList,
}, },
{ {
path: '/superset/sqllab/history/', path: '/sqllab/history/',
Component: QueryHistoryList, Component: QueryHistoryList,
}, },
{ {
@ -225,6 +229,10 @@ export const routes: Routes = [
path: '/profile', path: '/profile',
Component: Profile, Component: Profile,
}, },
{
path: '/sqllab/',
Component: SqlLab,
},
]; ];
if (isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM)) { if (isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM)) {

View File

@ -211,7 +211,6 @@ const config = {
menu: addPreamble('src/views/menu.tsx'), menu: addPreamble('src/views/menu.tsx'),
spa: addPreamble('/src/views/index.tsx'), spa: addPreamble('/src/views/index.tsx'),
embedded: addPreamble('/src/embedded/index.tsx'), embedded: addPreamble('/src/embedded/index.tsx'),
sqllab: addPreamble('/src/SqlLab/index.tsx'),
}, },
output, output,
stats: 'minimal', stats: 'minimal',

View File

@ -192,6 +192,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
TableSchemaView, TableSchemaView,
TabStateView, TabStateView,
) )
from superset.views.sqllab import SqllabView
from superset.views.tags import TagModelView, TagView from superset.views.tags import TagModelView, TagView
from superset.views.users.api import CurrentUserRestApi from superset.views.users.api import CurrentUserRestApi
@ -316,6 +317,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_view_no_menu(SavedQueryViewApi) appbuilder.add_view_no_menu(SavedQueryViewApi)
appbuilder.add_view_no_menu(SliceAsync) appbuilder.add_view_no_menu(SliceAsync)
appbuilder.add_view_no_menu(SqlLab) appbuilder.add_view_no_menu(SqlLab)
appbuilder.add_view_no_menu(SqllabView)
appbuilder.add_view_no_menu(SqlMetricInlineView) appbuilder.add_view_no_menu(SqlMetricInlineView)
appbuilder.add_view_no_menu(Superset) appbuilder.add_view_no_menu(Superset)
appbuilder.add_view_no_menu(TableColumnInlineView) appbuilder.add_view_no_menu(TableColumnInlineView)
@ -347,7 +349,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_link( appbuilder.add_link(
"SQL Editor", "SQL Editor",
label=__("SQL Lab"), label=__("SQL Lab"),
href="/superset/sqllab/", href="/sqllab/",
category_icon="fa-flask", category_icon="fa-flask",
icon="fa-flask", icon="fa-flask",
category="SQL Lab", category="SQL Lab",
@ -364,7 +366,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_link( appbuilder.add_link(
"Query Search", "Query Search",
label=__("Query History"), label=__("Query History"),
href="/superset/sqllab/history/", href="/sqllab/history/",
icon="fa-search", icon="fa-search",
category_icon="fa-flask", category_icon="fa-flask",
category="SQL Lab", category="SQL Lab",

View File

@ -157,7 +157,7 @@ class ExtraCache:
When in SQL Lab, it's possible to add arbitrary URL "query string" parameters, When in SQL Lab, it's possible to add arbitrary URL "query string" parameters,
and use those in your SQL code. For instance you can alter your url and add and use those in your SQL code. For instance you can alter your url and add
`?foo=bar`, as in `{domain}/superset/sqllab?foo=bar`. Then if your query is `?foo=bar`, as in `{domain}/sqllab?foo=bar`. Then if your query is
something like SELECT * FROM foo = '{{ url_param('foo') }}', it will be parsed something like SELECT * FROM foo = '{{ url_param('foo') }}', it will be parsed
at runtime and replaced by the value in the URL. at runtime and replaced by the value in the URL.

View File

@ -492,7 +492,7 @@ class Database(
source = utils.QuerySource.DASHBOARD source = utils.QuerySource.DASHBOARD
elif "/explore/" in request.referrer: elif "/explore/" in request.referrer:
source = utils.QuerySource.CHART source = utils.QuerySource.CHART
elif "/superset/sqllab" in request.referrer: elif "/sqllab/" in request.referrer:
source = utils.QuerySource.SQL_LAB source = utils.QuerySource.SQL_LAB
sqlalchemy_url, params = DB_CONNECTION_MUTATOR( sqlalchemy_url, params = DB_CONNECTION_MUTATOR(

View File

@ -408,7 +408,7 @@ class SavedQuery(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin):
def pop_tab_link(self) -> Markup: def pop_tab_link(self) -> Markup:
return Markup( return Markup(
f""" f"""
<a href="/superset/sqllab?savedQueryId={self.id}"> <a href="/sqllab?savedQueryId={self.id}">
<i class="fa fa-link"></i> <i class="fa fa-link"></i>
</a> </a>
""" """
@ -423,7 +423,7 @@ class SavedQuery(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin):
return self.database.sqlalchemy_uri return self.database.sqlalchemy_uri
def url(self) -> str: def url(self) -> str:
return f"/superset/sqllab?savedQueryId={self.id}" return f"/sqllab?savedQueryId={self.id}"
@property @property
def sql_tables(self) -> list[Table]: def sql_tables(self) -> list[Table]:

View File

@ -25,6 +25,7 @@ from flask_appbuilder.models.sqla.interface import SQLAInterface
from marshmallow import ValidationError from marshmallow import ValidationError
from superset import app, is_feature_enabled from superset import app, is_feature_enabled
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
from superset.daos.database import DatabaseDAO from superset.daos.database import DatabaseDAO
from superset.daos.query import QueryDAO from superset.daos.query import QueryDAO
from superset.extensions import event_logger from superset.extensions import event_logger
@ -67,6 +68,7 @@ logger = logging.getLogger(__name__)
class SqlLabRestApi(BaseSupersetApi): class SqlLabRestApi(BaseSupersetApi):
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
datamodel = SQLAInterface(Query) datamodel = SQLAInterface(Query)
resource_name = "sqllab" resource_name = "sqllab"

View File

@ -72,7 +72,6 @@ from superset.models.dashboard import Dashboard
from superset.models.slice import Slice from superset.models.slice import Slice
from superset.models.sql_lab import Query from superset.models.sql_lab import Query
from superset.models.user_attributes import UserAttribute from superset.models.user_attributes import UserAttribute
from superset.sqllab.utils import bootstrap_sqllab_data
from superset.superset_typing import FlaskResponse from superset.superset_typing import FlaskResponse
from superset.utils import core as utils from superset.utils import core as utils
from superset.utils.cache import etag_cache from superset.utils.cache import etag_cache
@ -982,28 +981,18 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
"POST", "POST",
), ),
) )
@deprecated(new_target="/sqllab")
def sqllab(self) -> FlaskResponse: def sqllab(self) -> FlaskResponse:
"""SQL Editor""" """SQL Editor"""
payload = { url = "/sqllab"
"common": common_bootstrap_payload(g.user), if url_params := request.args:
**bootstrap_sqllab_data(get_user_id()), params = parse.urlencode(url_params)
} url = f"{url}?{params}"
return redirect(url)
if form_data := request.form.get("form_data"):
with contextlib.suppress(json.JSONDecodeError):
payload["requested_query"] = json.loads(form_data)
payload["user"] = bootstrap_user_data(g.user, include_perms=True)
bootstrap_data = json.dumps(
payload, default=utils.pessimistic_json_iso_dttm_ser
)
return self.render_template(
"superset/basic.html", entry="sqllab", bootstrap_data=bootstrap_data
)
@has_access @has_access
@event_logger.log_this @event_logger.log_this
@expose("/sqllab/history/", methods=("GET",)) @expose("/sqllab/history/", methods=("GET",))
@event_logger.log_this @deprecated(new_target="/sqllab/history")
def sqllab_history(self) -> FlaskResponse: def sqllab_history(self) -> FlaskResponse:
return super().render_app_template() return redirect("/sqllab/history")

46
superset/views/sqllab.py Normal file
View File

@ -0,0 +1,46 @@
# 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.
from flask_appbuilder import permission_name
from flask_appbuilder.api import expose
from flask_appbuilder.security.decorators import has_access
from superset import event_logger
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
from superset.superset_typing import FlaskResponse
from .base import BaseSupersetView
class SqllabView(BaseSupersetView):
route_base = "/sqllab"
class_permission_name = "SQLLab"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
@expose("/")
@has_access
@permission_name("read")
@event_logger.log_this
def root(self) -> FlaskResponse:
return self.render_app_template()
@expose("/history/", methods=("GET",))
@has_access
@permission_name("read")
@event_logger.log_this
def history(self) -> FlaskResponse:
return self.render_app_template()

View File

@ -49,7 +49,6 @@ from superset.models.dashboard import Dashboard
from superset.models.slice import Slice from superset.models.slice import Slice
from superset.models.sql_lab import Query from superset.models.sql_lab import Query
from superset.result_set import SupersetResultSet from superset.result_set import SupersetResultSet
from superset.sqllab.utils import bootstrap_sqllab_data
from superset.utils import core as utils from superset.utils import core as utils
from superset.utils.core import backend from superset.utils.core import backend
from superset.utils.database import get_example_database from superset.utils.database import get_example_database
@ -956,7 +955,6 @@ class TestCore(SupersetTestCase):
dash_id = db.session.query(Dashboard.id).first()[0] dash_id = db.session.query(Dashboard.id).first()[0]
tbl_id = self.table_ids.get("wb_health_population") tbl_id = self.table_ids.get("wb_health_population")
urls = [ urls = [
"/superset/sqllab",
"/superset/welcome", "/superset/welcome",
f"/superset/dashboard/{dash_id}/", f"/superset/dashboard/{dash_id}/",
"/superset/profile/", "/superset/profile/",
@ -1161,6 +1159,25 @@ class TestCore(SupersetTestCase):
resp = self.client.get("/superset/profile/") resp = self.client.get("/superset/profile/")
assert resp.status_code == 302 assert resp.status_code == 302
def test_redirect_new_sqllab(self):
self.login(username="admin")
resp = self.client.get(
"/superset/sqllab?savedQueryId=1&testParams=2",
follow_redirects=True,
)
assert resp.request.path == "/sqllab/"
assert (
resp.request.query_string.decode("utf-8") == "savedQueryId=1&testParams=2"
)
resp = self.client.post("/superset/sqllab/")
assert resp.status_code == 302
def test_redirect_new_sqllab_history(self):
self.login(username="admin")
resp = self.client.get("/superset/sqllab/history/")
assert resp.status_code == 302
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -259,7 +259,7 @@ class TestSqlLab(SupersetTestCase):
def test_sqllab_has_access(self): def test_sqllab_has_access(self):
for username in ("admin", "gamma_sqllab"): for username in ("admin", "gamma_sqllab"):
self.login(username) self.login(username)
for endpoint in ("/superset/sqllab/", "/superset/sqllab/history/"): for endpoint in ("/sqllab/", "/sqllab/history/"):
resp = self.client.get(endpoint) resp = self.client.get(endpoint)
self.assertEqual(200, resp.status_code) self.assertEqual(200, resp.status_code)
@ -267,7 +267,7 @@ class TestSqlLab(SupersetTestCase):
def test_sqllab_no_access(self): def test_sqllab_no_access(self):
self.login("gamma") self.login("gamma")
for endpoint in ("/superset/sqllab/", "/superset/sqllab/history/"): for endpoint in ("/sqllab/", "/sqllab/history/"):
resp = self.client.get(endpoint) resp = self.client.get(endpoint)
# Redirects to the main page # Redirects to the main page
self.assertEqual(302, resp.status_code) self.assertEqual(302, resp.status_code)