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', () => {
beforeEach(() => {
cy.visit('/superset/sqllab');
cy.visit('/sqllab');
});
// 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', () => {
beforeEach(() => {
cy.visit('/superset/sqllab');
cy.visit('/sqllab');
});
it.skip('supports entering and running a query', () => {

View File

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

View File

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

View File

@ -77984,7 +77984,7 @@
"@mapbox/geojson-extent": "^1.0.1",
"@math.gl/web-mercator": "^3.2.2",
"@types/d3-array": "^2.0.0",
"@types/mapbox__geojson-extent": "*",
"@types/mapbox__geojson-extent": "^1.0.0",
"@types/underscore": "^1.11.6",
"@types/urijs": "^1.19.19",
"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 explore from 'src/explore/reducers/exploreReducer';
import sqlLab from 'src/SqlLab/reducers/sqlLab';
import localStorageUsageInKilobytes from 'src/SqlLab/reducers/localStorageUsage';
import reports from 'src/features/reports/ReportModal/reducer';
import getBootstrapData from 'src/utils/getBootstrapData';
@ -59,7 +58,7 @@ export default {
saveModal,
explore,
sqlLab,
localStorageUsageInKilobytes,
localStorageUsageInKilobytes: noopReducer(0),
reports,
common: noopReducer(common),
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 { addTable } from 'src/SqlLab/actions/sqlLab';
import { initialState } from 'src/SqlLab/fixtures';
import { reducers } from 'src/SqlLab/reducers';
import reducers from 'spec/helpers/reducerIndex';
import {
SCHEMA_AUTOCOMPLETE_SCORE,
TABLE_AUTOCOMPLETE_SCORE,

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import { fireEvent, render, waitFor } from 'spec/helpers/testing-library';
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 { setupStore } from 'src/views/store';
import {

View File

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

View File

@ -110,23 +110,17 @@ describe('TabbedSqlEditors', () => {
it('should handle id', async () => {
uriStub.returns({ id: 1 });
await mountWithAct();
expect(window.history.replaceState.getCall(0).args[2]).toBe(
'/superset/sqllab',
);
expect(window.history.replaceState.getCall(0).args[2]).toBe('/sqllab');
});
it('should handle savedQueryId', async () => {
uriStub.returns({ savedQueryId: 1 });
await mountWithAct();
expect(window.history.replaceState.getCall(0).args[2]).toBe(
'/superset/sqllab',
);
expect(window.history.replaceState.getCall(0).args[2]).toBe('/sqllab');
});
it('should handle sql', async () => {
uriStub.returns({ sql: 1, dbid: 1 });
await mountWithAct();
expect(window.history.replaceState.getCall(0).args[2]).toBe(
'/superset/sqllab',
);
expect(window.history.replaceState.getCall(0).args[2]).toBe('/sqllab');
});
it('should handle custom url params', async () => {
uriStub.returns({
@ -137,7 +131,7 @@ describe('TabbedSqlEditors', () => {
});
await mountWithAct();
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 { EmptyStateBig } from 'src/components/EmptyState';
import getBootstrapData from 'src/utils/getBootstrapData';
import { locationContext } from 'src/pages/SqlLab/LocationContext';
import SqlEditor from '../SqlEditor';
import SqlEditorTabHeader from '../SqlEditorTabHeader';
@ -75,7 +76,7 @@ const userOS = detectOS();
class TabbedSqlEditors extends React.PureComponent {
constructor(props) {
super(props);
const sqlLabUrl = '/superset/sqllab';
const sqlLabUrl = '/sqllab';
this.state = {
sqlLabUrl,
};
@ -132,6 +133,7 @@ class TabbedSqlEditors extends React.PureComponent {
new: isNewQuery,
...urlParams
} = {
...this.context.requestedQuery,
...bootstrapData.requested_query,
...queryParameters,
};
@ -332,6 +334,7 @@ class TabbedSqlEditors extends React.PureComponent {
}
TabbedSqlEditors.propTypes = propTypes;
TabbedSqlEditors.defaultProps = defaultProps;
TabbedSqlEditors.contextType = locationContext;
function mapStateToProps({ sqlLab, common }) {
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 { Logger, LOG_ACTIONS_LOAD_CHART } from 'src/logger/LogUtils';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import { safeStringify } from 'src/utils/safeStringify';
import { allowCrossDomain as domainShardingEnabled } from 'src/utils/hostNamesConfig';
import { updateDataMask } from 'src/dataMask/actions';
import { waitForAsyncData } from 'src/middleware/asyncEvent';
@ -571,17 +570,20 @@ export function postChartFormData(
);
}
export function redirectSQLLab(formData) {
export function redirectSQLLab(formData, history) {
return dispatch => {
getChartDataRequest({ formData, resultFormat: 'json', resultType: 'query' })
.then(({ json }) => {
const redirectUrl = '/superset/sqllab/';
const redirectUrl = '/sqllab/';
const payload = {
datasourceKey: formData.datasource,
sql: json.result[0].query,
};
SupersetClient.postForm(redirectUrl, {
form_data: safeStringify(payload),
history.push({
pathname: redirectUrl,
state: {
requestedQuery: payload,
},
});
})
.catch(() =>

View File

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

View File

@ -18,6 +18,7 @@
*/
import React from 'react';
import { Route } from 'react-router-dom';
import fetchMock from 'fetch-mock';
import userEvent from '@testing-library/user-event';
import { DatasourceType, JsonObject, SupersetClient } from '@superset-ui/core';
@ -27,6 +28,17 @@ import DatasourceControl from '.';
const SupersetClientGet = jest.spyOn(SupersetClient, 'get');
const mockDatasource = {
id: 25,
database: {
name: 'examples',
},
name: 'channels',
type: 'table',
columns: [],
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',
@ -35,16 +47,7 @@ const createProps = (overrides: JsonObject = {}) => ({
description: null,
value: '25__table',
form_data: {},
datasource: {
id: 25,
database: {
name: 'examples',
},
name: 'channels',
type: 'table',
columns: [],
owners: [{ first_name: 'john', last_name: 'doe', id: 1, username: 'jd' }],
},
datasource: mockDatasource,
validationErrors: [],
name: 'datasource',
actions: {
@ -91,20 +94,20 @@ async function openAndSaveChanges(datasource: any) {
test('Should render', async () => {
const props = createProps();
render(<DatasourceControl {...props} />);
render(<DatasourceControl {...props} />, { useRouter: true });
expect(await screen.findByTestId('datasource-control')).toBeVisible();
});
test('Should have elements', async () => {
const props = createProps();
render(<DatasourceControl {...props} />);
render(<DatasourceControl {...props} />, { useRouter: true });
expect(await screen.findByText('channels')).toBeVisible();
expect(screen.getByTestId('datasource-menu-trigger')).toBeVisible();
});
test('Should open a menu', async () => {
const props = createProps();
render(<DatasourceControl {...props} />);
render(<DatasourceControl {...props} />, { useRouter: true });
expect(screen.queryByText('Edit 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',
},
});
render(<DatasourceControl {...props} />);
render(<DatasourceControl {...props} />, { useRouter: true });
userEvent.click(screen.getByTestId('datasource-menu-trigger'));
@ -154,7 +157,7 @@ test('Should show SQL Lab for sql_lab role', async () => {
username: 'sql',
},
});
render(<DatasourceControl {...props} />);
render(<DatasourceControl {...props} />, { useRouter: true });
userEvent.click(screen.getByTestId('datasource-menu-trigger'));
@ -178,6 +181,7 @@ test('Click on Swap dataset option', async () => {
render(<DatasourceControl {...props} />, {
useRedux: true,
useRouter: true,
});
userEvent.click(screen.getByTestId('datasource-menu-trigger'));
@ -198,6 +202,7 @@ test('Click on Edit dataset', async () => {
);
render(<DatasourceControl {...props} />, {
useRedux: true,
useRouter: true,
});
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} />, {
useRedux: true,
useRouter: true,
});
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 () => {
const props = createProps();
const postFormSpy = jest.spyOn(SupersetClient, 'postForm');
postFormSpy.mockImplementation(jest.fn());
render(<DatasourceControl {...props} />, {
useRedux: true,
});
const { queryByTestId, getByTestId } = render(
<>
<Route
path="/sqllab"
render={({ location }) => (
<div data-test="mock-sqllab-route">
{JSON.stringify(location.state)}
</div>
)}
/>
<DatasourceControl {...props} />
</>,
{
useRedux: true,
useRouter: true,
},
);
userEvent.click(screen.getByTestId('datasource-menu-trigger'));
expect(postFormSpy).toBeCalledTimes(0);
expect(queryByTestId('mock-sqllab-route')).not.toBeInTheDocument();
await act(async () => {
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 () => {
@ -261,7 +287,7 @@ test('Should open a different menu when datasource=query', async () => {
type: DatasourceType.Query,
},
};
render(<DatasourceControl {...queryProps} />);
render(<DatasourceControl {...queryProps} />, { useRouter: true });
expect(screen.queryByText('Query preview')).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.getByText('Save as dataset'));
@ -327,6 +356,7 @@ test('should set the default temporal column', async () => {
};
render(<DatasourceControl {...props} {...overrideProps} />, {
useRedux: true,
useRouter: true,
});
await openAndSaveChanges(overrideProps.datasource);
@ -362,6 +392,7 @@ test('should set the first available temporal column', async () => {
};
render(<DatasourceControl {...props} {...overrideProps} />, {
useRedux: true,
useRouter: true,
});
await openAndSaveChanges(overrideProps.datasource);
@ -397,6 +428,7 @@ test('should not set the temporal column', async () => {
};
render(<DatasourceControl {...props} {...overrideProps} />, {
useRedux: true,
useRouter: true,
});
await openAndSaveChanges(overrideProps.datasource);
@ -410,7 +442,7 @@ test('should not set the temporal column', async () => {
test('should show missing params state', () => {
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 url parameters/i)).toBeVisible();
expect(
@ -426,7 +458,7 @@ test('should show missing dataset state', () => {
// @ts-ignore
window.location = { search: '?slice_id=152' };
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.getByText(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -105,7 +105,7 @@ const getEntityIcon = (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;
return entity.item_url;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,10 @@
* under the License.
*/
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 {
SupersetClient,
@ -35,7 +38,9 @@ export const supersetClientQuery: BaseQueryFn<
parseMethod?: ParseMethod;
transformResponse?: (response: SupersetClientResponse) => JsonValue;
urlParams?: Record<string, number | string | undefined | boolean>;
}
},
JsonValue,
ClientErrorObject
> = (
{
endpoint,

View File

@ -31,7 +31,7 @@ jest.mock('react-router-dom', () => ({
describe('AddDataset', () => {
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 });

View File

@ -17,6 +17,7 @@
* under the License.
*/
import React, { useMemo, useState, useCallback, ReactElement } from 'react';
import { Link, useHistory } from 'react-router-dom';
import {
QueryState,
styled,
@ -102,6 +103,7 @@ function QueryList({ addDangerToast }: QueryListProps) {
useState<QueryObject>();
const theme = useTheme();
const history = useHistory();
const handleQueryPreview = useCallback(
(id: number) => {
@ -334,9 +336,9 @@ function QueryList({ addDangerToast }: QueryListProps) {
},
}: any) => (
<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} />
</a>
</Link>
</Tooltip>
),
},
@ -427,9 +429,7 @@ function QueryList({ addDangerToast }: QueryListProps) {
query={queryCurrentlyPreviewing}
queries={queries}
fetchData={handleQueryPreview}
openInSqlLab={(id: number) =>
window.location.assign(`/superset/sqllab?queryId=${id}`)
}
openInSqlLab={(id: number) => history.push(`/sqllab?queryId=${id}`)}
show
/>
)}

View File

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

View File

@ -16,17 +16,26 @@
* specific language governing permissions and limitations
* 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 = {
sqlLab,
localStorageUsageInKilobytes,
messageToasts,
common,
import React, { createContext, useContext } from 'react';
import { useLocation } from 'react-router-dom';
export type LocationState = {
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,
) => {
copyTextToClipboard(() =>
Promise.resolve(
`${window.location.origin}/superset/sqllab?savedQueryId=${id}`,
),
Promise.resolve(`${window.location.origin}/sqllab?savedQueryId=${id}`),
)
.then(() => {
addSuccessToast(t('Link Copied!'));

View File

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

View File

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

View File

@ -192,6 +192,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
TableSchemaView,
TabStateView,
)
from superset.views.sqllab import SqllabView
from superset.views.tags import TagModelView, TagView
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(SliceAsync)
appbuilder.add_view_no_menu(SqlLab)
appbuilder.add_view_no_menu(SqllabView)
appbuilder.add_view_no_menu(SqlMetricInlineView)
appbuilder.add_view_no_menu(Superset)
appbuilder.add_view_no_menu(TableColumnInlineView)
@ -347,7 +349,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_link(
"SQL Editor",
label=__("SQL Lab"),
href="/superset/sqllab/",
href="/sqllab/",
category_icon="fa-flask",
icon="fa-flask",
category="SQL Lab",
@ -364,7 +366,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_link(
"Query Search",
label=__("Query History"),
href="/superset/sqllab/history/",
href="/sqllab/history/",
icon="fa-search",
category_icon="fa-flask",
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,
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
at runtime and replaced by the value in the URL.

View File

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

View File

@ -408,7 +408,7 @@ class SavedQuery(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin):
def pop_tab_link(self) -> Markup:
return Markup(
f"""
<a href="/superset/sqllab?savedQueryId={self.id}">
<a href="/sqllab?savedQueryId={self.id}">
<i class="fa fa-link"></i>
</a>
"""
@ -423,7 +423,7 @@ class SavedQuery(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin):
return self.database.sqlalchemy_uri
def url(self) -> str:
return f"/superset/sqllab?savedQueryId={self.id}"
return f"/sqllab?savedQueryId={self.id}"
@property
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 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.query import QueryDAO
from superset.extensions import event_logger
@ -67,6 +68,7 @@ logger = logging.getLogger(__name__)
class SqlLabRestApi(BaseSupersetApi):
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
datamodel = SQLAInterface(Query)
resource_name = "sqllab"

View File

@ -72,7 +72,6 @@ from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.models.sql_lab import Query
from superset.models.user_attributes import UserAttribute
from superset.sqllab.utils import bootstrap_sqllab_data
from superset.superset_typing import FlaskResponse
from superset.utils import core as utils
from superset.utils.cache import etag_cache
@ -982,28 +981,18 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
"POST",
),
)
@deprecated(new_target="/sqllab")
def sqllab(self) -> FlaskResponse:
"""SQL Editor"""
payload = {
"common": common_bootstrap_payload(g.user),
**bootstrap_sqllab_data(get_user_id()),
}
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
)
url = "/sqllab"
if url_params := request.args:
params = parse.urlencode(url_params)
url = f"{url}?{params}"
return redirect(url)
@has_access
@event_logger.log_this
@expose("/sqllab/history/", methods=("GET",))
@event_logger.log_this
@deprecated(new_target="/sqllab/history")
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.sql_lab import Query
from superset.result_set import SupersetResultSet
from superset.sqllab.utils import bootstrap_sqllab_data
from superset.utils import core as utils
from superset.utils.core import backend
from superset.utils.database import get_example_database
@ -956,7 +955,6 @@ class TestCore(SupersetTestCase):
dash_id = db.session.query(Dashboard.id).first()[0]
tbl_id = self.table_ids.get("wb_health_population")
urls = [
"/superset/sqllab",
"/superset/welcome",
f"/superset/dashboard/{dash_id}/",
"/superset/profile/",
@ -1161,6 +1159,25 @@ class TestCore(SupersetTestCase):
resp = self.client.get("/superset/profile/")
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__":
unittest.main()

View File

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