From 5ab1e7eae45b789c08c0b99612b4a410bbb986b8 Mon Sep 17 00:00:00 2001 From: "JUST.in DO IT" Date: Wed, 4 Oct 2023 12:21:41 -0700 Subject: [PATCH] feat(sqllab): SPA migration (#25151) --- .../sqllab/_skip.sourcePanel.index.test.js | 2 +- .../cypress/e2e/sqllab/query.test.ts | 2 +- .../e2e/sqllab/sqllab.applitools.test.ts | 2 +- .../cypress/e2e/sqllab/tabs.test.ts | 2 +- superset-frontend/package-lock.json | 2 +- .../spec/helpers/reducerIndex.ts | 3 +- superset-frontend/src/SqlLab/App.jsx | 84 ---------------- .../AceEditorWrapper/useKeywords.test.ts | 2 +- .../src/SqlLab/components/App/App.test.jsx | 5 +- .../src/SqlLab/components/App/index.jsx | 12 ++- .../SqlLab/components/QueryTable/index.tsx | 2 +- .../components/SqlEditor/SqlEditor.test.jsx | 2 +- .../SqlEditorLeftBar.test.jsx | 2 +- .../TabbedSqlEditors.test.jsx | 14 +-- .../components/TabbedSqlEditors/index.jsx | 5 +- superset-frontend/src/SqlLab/index.tsx | 23 ----- .../src/SqlLab/reducers/common.js | 21 ---- .../src/SqlLab/reducers/localStorageUsage.js | 21 ---- .../src/components/Chart/chartAction.js | 12 ++- .../components/ExploreChartHeader/index.jsx | 13 ++- .../DatasourceControl.test.tsx | 84 +++++++++++----- .../controls/DatasourceControl/index.jsx | 50 +++++----- .../controls/ViewQueryModalFooter.tsx | 11 ++- .../databases/DatabaseModal/index.tsx | 14 +-- .../DatasetPanel/DatasetPanel.test.tsx | 19 +++- .../DatasetPanel/MessageContent.tsx | 15 ++- .../DatasetLayout/DatasetLayout.test.tsx | 15 ++- .../src/features/home/ActivityTable.tsx | 2 +- .../src/features/home/EmptyState.tsx | 28 +++--- .../src/features/home/Menu.test.tsx | 2 +- .../src/features/home/RightMenu.test.tsx | 2 +- .../src/features/home/RightMenu.tsx | 2 +- .../src/features/home/SavedQueries.tsx | 25 ++--- .../src/features/home/SubMenu.tsx | 2 +- .../src/features/home/commonMenuData.ts | 2 +- .../src/hooks/apiResources/queryApi.ts | 9 +- .../DatasetCreation/DatasetCreation.test.tsx | 2 +- .../src/pages/QueryHistoryList/index.tsx | 10 +- .../src/pages/SavedQueryList/index.tsx | 17 ++-- .../SqlLab/LocationContext.tsx} | 31 +++--- .../src/pages/SqlLab/SqlLab.test.tsx | 99 +++++++++++++++++++ superset-frontend/src/pages/SqlLab/index.tsx | 78 +++++++++++++++ superset-frontend/src/views/CRUD/hooks.ts | 4 +- superset-frontend/src/views/routes.tsx | 10 +- superset-frontend/webpack.config.js | 1 - superset/initialization/__init__.py | 6 +- superset/jinja_context.py | 2 +- superset/models/core.py | 2 +- superset/models/sql_lab.py | 4 +- superset/sqllab/api.py | 2 + superset/views/core.py | 27 ++--- superset/views/sqllab.py | 46 +++++++++ tests/integration_tests/core_tests.py | 21 +++- tests/integration_tests/sqllab_tests.py | 4 +- 54 files changed, 518 insertions(+), 361 deletions(-) delete mode 100644 superset-frontend/src/SqlLab/App.jsx delete mode 100644 superset-frontend/src/SqlLab/index.tsx delete mode 100644 superset-frontend/src/SqlLab/reducers/common.js delete mode 100644 superset-frontend/src/SqlLab/reducers/localStorageUsage.js rename superset-frontend/src/{SqlLab/reducers/index.js => pages/SqlLab/LocationContext.tsx} (56%) create mode 100644 superset-frontend/src/pages/SqlLab/SqlLab.test.tsx create mode 100644 superset-frontend/src/pages/SqlLab/index.tsx create mode 100644 superset/views/sqllab.py diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/_skip.sourcePanel.index.test.js b/superset-frontend/cypress-base/cypress/e2e/sqllab/_skip.sourcePanel.index.test.js index be455a4a99..ece1581714 100644 --- a/superset-frontend/cypress-base/cypress/e2e/sqllab/_skip.sourcePanel.index.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/sqllab/_skip.sourcePanel.index.test.js @@ -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 diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/query.test.ts b/superset-frontend/cypress-base/cypress/e2e/sqllab/query.test.ts index 0d36692b2a..86502e8655 100644 --- a/superset-frontend/cypress-base/cypress/e2e/sqllab/query.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/sqllab/query.test.ts @@ -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', () => { diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/sqllab.applitools.test.ts b/superset-frontend/cypress-base/cypress/e2e/sqllab/sqllab.applitools.test.ts index fdbaefb158..cc4cf7ac03 100644 --- a/superset-frontend/cypress-base/cypress/e2e/sqllab/sqllab.applitools.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/sqllab/sqllab.applitools.test.ts @@ -19,7 +19,7 @@ describe('SqlLab view', () => { beforeEach(() => { - cy.visit('/superset/sqllab'); + cy.visit('/sqllab'); }); it('should load the SqlLab', () => { diff --git a/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts b/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts index b2c7a180ad..0deeabde8d 100644 --- a/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/sqllab/tabs.test.ts @@ -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"]'; diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 694cfd9193..4c0fa255e1 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -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", diff --git a/superset-frontend/spec/helpers/reducerIndex.ts b/superset-frontend/spec/helpers/reducerIndex.ts index a9cadc4f81..95fe4d3f1c 100644 --- a/superset-frontend/spec/helpers/reducerIndex.ts +++ b/superset-frontend/spec/helpers/reducerIndex.ts @@ -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), diff --git a/superset-frontend/src/SqlLab/App.jsx b/superset-frontend/src/SqlLab/App.jsx deleted file mode 100644 index ae8b81f4a8..0000000000 --- a/superset-frontend/src/SqlLab/App.jsx +++ /dev/null @@ -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 = () => ( - - - - - - - -); - -export default hot(Application); diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts index 12bd95b402..7aa306d8bc 100644 --- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts +++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts @@ -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, diff --git a/superset-frontend/src/SqlLab/components/App/App.test.jsx b/superset-frontend/src/SqlLab/components/App/App.test.jsx index d56ea4780e..d3db1d5fb8 100644 --- a/superset-frontend/src/SqlLab/components/App/App.test.jsx +++ b/superset-frontend/src/SqlLab/components/App/App.test.jsx @@ -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', () => () => (
)); +const sqlLabReducer = combineReducers(reducers); + describe('SqlLab App', () => { const middlewares = [thunk]; const mockStore = configureStore(middlewares); diff --git a/superset-frontend/src/SqlLab/components/App/index.jsx b/superset-frontend/src/SqlLab/components/App/index.jsx index ff47e6173b..aab4e78d4f 100644 --- a/superset-frontend/src/SqlLab/components/App/index.jsx +++ b/superset-frontend/src/SqlLab/components/App/index.jsx @@ -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 ( + + ); } return ( @@ -195,7 +202,6 @@ class App extends React.PureComponent { queriesLastUpdate={queriesLastUpdate} /> - ); } diff --git a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx index b5eaeb01e6..6ddae08e68 100644 --- a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx +++ b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx @@ -61,7 +61,7 @@ interface QueryTableProps { } const openQuery = (id: number) => { - const url = `/superset/sqllab?queryId=${id}`; + const url = `/sqllab?queryId=${id}`; window.open(url); }; diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx index 23424ff264..ff335e14ea 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx @@ -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 { diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.jsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.jsx index d12938a235..6665091572 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.jsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.jsx @@ -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], diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx index 90d1de2528..5d782590a1 100644 --- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx +++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx @@ -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', ); }); }); diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx index 95d0c2529b..166cce18f9 100644 --- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx +++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx @@ -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 { diff --git a/superset-frontend/src/SqlLab/index.tsx b/superset-frontend/src/SqlLab/index.tsx deleted file mode 100644 index c257009e64..0000000000 --- a/superset-frontend/src/SqlLab/index.tsx +++ /dev/null @@ -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(, document.getElementById('app')); diff --git a/superset-frontend/src/SqlLab/reducers/common.js b/superset-frontend/src/SqlLab/reducers/common.js deleted file mode 100644 index 05a7968a88..0000000000 --- a/superset-frontend/src/SqlLab/reducers/common.js +++ /dev/null @@ -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; -} diff --git a/superset-frontend/src/SqlLab/reducers/localStorageUsage.js b/superset-frontend/src/SqlLab/reducers/localStorageUsage.js deleted file mode 100644 index eafbb07816..0000000000 --- a/superset-frontend/src/SqlLab/reducers/localStorageUsage.js +++ /dev/null @@ -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; -} diff --git a/superset-frontend/src/components/Chart/chartAction.js b/superset-frontend/src/components/Chart/chartAction.js index d1dcfd3a00..fcf45a4946 100644 --- a/superset-frontend/src/components/Chart/chartAction.js +++ b/superset-frontend/src/components/Chart/chartAction.js @@ -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(() => diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx index 958aa16a31..6e11eaf1c5 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx @@ -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, diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx b/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx index 6def65d7d2..4531719246 100644 --- a/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx +++ b/superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx @@ -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(); + render(, { useRouter: true }); expect(await screen.findByTestId('datasource-control')).toBeVisible(); }); test('Should have elements', async () => { const props = createProps(); - render(); + render(, { 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(); + render(, { 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(); + render(, { 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(); + render(, { useRouter: true }); userEvent.click(screen.getByTestId('datasource-menu-trigger')); @@ -178,6 +181,7 @@ test('Click on Swap dataset option', async () => { render(, { useRedux: true, + useRouter: true, }); userEvent.click(screen.getByTestId('datasource-menu-trigger')); @@ -198,6 +202,7 @@ test('Click on Edit dataset', async () => { ); render(, { 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(, { 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(, { - useRedux: true, - }); + const { queryByTestId, getByTestId } = render( + <> + ( +
+ {JSON.stringify(location.state)} +
+ )} + /> + + , + { + 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(); + render(, { 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(, { useRedux: true }); + render(, { + 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(, { useRedux: true, + useRouter: true, }); await openAndSaveChanges(overrideProps.datasource); @@ -362,6 +392,7 @@ test('should set the first available temporal column', async () => { }; render(, { useRedux: true, + useRouter: true, }); await openAndSaveChanges(overrideProps.datasource); @@ -397,6 +428,7 @@ test('should not set the temporal column', async () => { }; render(, { 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(, { useRedux: true }); + render(, { 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(, { useRedux: true }); + render(, { useRedux: true, useRouter: true }); expect(screen.getAllByText(/missing dataset/i)).toHaveLength(2); expect( screen.getByText( diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx b/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx index bf85716206..707138d506 100644 --- a/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx +++ b/superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx @@ -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 = ( @@ -310,7 +294,16 @@ class DatasourceControl extends React.PureComponent { )} {t('Swap dataset')} {!isMissingDatasource && canAccessSqlLab && ( - {t('View in SQL Lab')} + + + {t('View in SQL Lab')} + + )} ); @@ -340,7 +333,16 @@ class DatasourceControl extends React.PureComponent { /> {canAccessSqlLab && ( - {t('View in SQL Lab')} + + + {t('View in SQL Lab')} + + )} {t('Save as dataset')} diff --git a/superset-frontend/src/explore/components/controls/ViewQueryModalFooter.tsx b/superset-frontend/src/explore/components/controls/ViewQueryModalFooter.tsx index 4f4af039b1..fbc87d7f9f 100644 --- a/superset-frontend/src/explore/components/controls/ViewQueryModalFooter.tsx +++ b/superset-frontend/src/explore/components/controls/ViewQueryModalFooter.tsx @@ -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 = (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 = () => { diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/index.tsx index 555b21be79..0c1ac56369 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx @@ -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 = ({ show, databaseId, dbEngine, - history, }) => { const [db, setDB] = useReducer< Reducer | null, DBReducerActionType> @@ -627,6 +626,7 @@ const DatabaseModal: FunctionComponent = ({ (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 = ({ }; 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 = ({ onClick={() => { setLoading(true); fetchAndSetDB(); - redirectURL(`/superset/sqllab/?db=true`); + redirectURL(`/sqllab?db=true`); }} > {t('QUERY DATA IN SQL LAB')} diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.test.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.test.tsx index 19262c91bc..62fdc0dfd0 100644 --- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.test.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/DatasetPanel.test.tsx @@ -45,7 +45,9 @@ jest.mock( describe('DatasetPanel', () => { test('renders a blank state DatasetPanel', () => { - render(); + render(, { + 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 diff --git a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/MessageContent.tsx b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/MessageContent.tsx index 5d0ef5eda7..6824e1c501 100644 --- a/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/MessageContent.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/DatasetPanel/MessageContent.tsx @@ -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} - { - window.location.href = `/superset/sqllab`; - }} - tabIndex={0} - > - {CREATE_MESSAGE} - + + + {CREATE_MESSAGE} + + {VIEW_DATASET_MESSAGE} ); diff --git a/superset-frontend/src/features/datasets/DatasetLayout/DatasetLayout.test.tsx b/superset-frontend/src/features/datasets/DatasetLayout/DatasetLayout.test.tsx index 66cbf6f0c4..36278ed3dd 100644 --- a/superset-frontend/src/features/datasets/DatasetLayout/DatasetLayout.test.tsx +++ b/superset-frontend/src/features/datasets/DatasetLayout/DatasetLayout.test.tsx @@ -35,7 +35,7 @@ jest.mock('react-router-dom', () => ({ describe('DatasetLayout', () => { it('renders nothing when no components are passed in', () => { - render(); + render(, { 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( null} />} />, - { useRedux: true }, + { useRedux: true, useRouter: true }, ); expect( @@ -65,7 +65,9 @@ describe('DatasetLayout', () => { }); it('renders a DatasetPanel when passed in', () => { - render(} />); + render(} />, { + 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(); + render(, { useRouter: true }); expect(screen.getByText(/right panel/i)).toBeVisible(); }); it('renders a Footer when passed in', () => { - render(} />, { useRedux: true }); + render(} />, { + useRedux: true, + useRouter: true, + }); expect(screen.getByText(/Cancel/i)).toBeVisible(); }); diff --git a/superset-frontend/src/features/home/ActivityTable.tsx b/superset-frontend/src/features/home/ActivityTable.tsx index cd38c021f8..b3f43eac5e 100644 --- a/superset-frontend/src/features/home/ActivityTable.tsx +++ b/superset-frontend/src/features/home/ActivityTable.tsx @@ -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; }; diff --git a/superset-frontend/src/features/home/EmptyState.tsx b/superset-frontend/src/features/home/EmptyState.tsx index 47e7817ae3..d36d1bdbd6 100644 --- a/superset-frontend/src/features/home/EmptyState.tsx +++ b/superset-frontend/src/features/home/EmptyState.tsx @@ -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 && ( - + + + )} diff --git a/superset-frontend/src/features/home/Menu.test.tsx b/superset-frontend/src/features/home/Menu.test.tsx index b40a5ab075..428a7366f0 100644 --- a/superset-frontend/src/features/home/Menu.test.tsx +++ b/superset-frontend/src/features/home/Menu.test.tsx @@ -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', diff --git a/superset-frontend/src/features/home/RightMenu.test.tsx b/superset-frontend/src/features/home/RightMenu.test.tsx index 95d61def4c..97b9fb20bd 100644 --- a/superset-frontend/src/features/home/RightMenu.test.tsx +++ b/superset-frontend/src/features/home/RightMenu.test.tsx @@ -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', diff --git a/superset-frontend/src/features/home/RightMenu.tsx b/superset-frontend/src/features/home/RightMenu.tsx index 831ae85ba3..b79ebb65f8 100644 --- a/superset-frontend/src/features/home/RightMenu.tsx +++ b/superset-frontend/src/features/home/RightMenu.tsx @@ -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', diff --git a/superset-frontend/src/features/home/SavedQueries.tsx b/superset-frontend/src/features/home/SavedQueries.tsx index 9417f03bea..f5ac37563f 100644 --- a/superset-frontend/src/features/home/SavedQueries.tsx +++ b/superset-frontend/src/features/home/SavedQueries.tsx @@ -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) => ( {canEdit && ( - { - window.location.href = `/superset/sqllab?savedQueryId=${query.id}`; - }} - > - {t('Edit')} + + {t('Edit')} )} + {t('SQL Query')} - + ), buttonStyle: 'tertiary', - onClick: () => { - window.location.href = '/superset/sqllab?new=true'; - }, }, { name: t('View All ยป'), @@ -278,15 +272,10 @@ const SavedQueries = ({ {queries.length > 0 ? ( {queries.map(q => ( - { - window.location.href = `/superset/sqllab?savedQueryId=${q.id}`; - }} - key={q.id} - > + JsonValue; urlParams?: Record; - } + }, + JsonValue, + ClientErrorObject > = ( { endpoint, diff --git a/superset-frontend/src/pages/DatasetCreation/DatasetCreation.test.tsx b/superset-frontend/src/pages/DatasetCreation/DatasetCreation.test.tsx index 41b32965e8..8f41228315 100644 --- a/superset-frontend/src/pages/DatasetCreation/DatasetCreation.test.tsx +++ b/superset-frontend/src/pages/DatasetCreation/DatasetCreation.test.tsx @@ -31,7 +31,7 @@ jest.mock('react-router-dom', () => ({ describe('AddDataset', () => { it('renders a blank state AddDataset', async () => { - render(, { useRedux: true }); + render(, { useRedux: true, useRouter: true }); const blankeStateImgs = screen.getAllByRole('img', { name: /empty/i }); diff --git a/superset-frontend/src/pages/QueryHistoryList/index.tsx b/superset-frontend/src/pages/QueryHistoryList/index.tsx index 1d735fd69e..63e916e399 100644 --- a/superset-frontend/src/pages/QueryHistoryList/index.tsx +++ b/superset-frontend/src/pages/QueryHistoryList/index.tsx @@ -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(); const theme = useTheme(); + const history = useHistory(); const handleQueryPreview = useCallback( (id: number) => { @@ -334,9 +336,9 @@ function QueryList({ addDangerToast }: QueryListProps) { }, }: any) => ( - + - + ), }, @@ -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 /> )} diff --git a/superset-frontend/src/pages/SavedQueryList/index.tsx b/superset-frontend/src/pages/SavedQueryList/index.tsx index a2f3479b90..8c1ce2b3dd 100644 --- a/superset-frontend/src/pages/SavedQueryList/index.tsx +++ b/superset-frontend/src/pages/SavedQueryList/index.tsx @@ -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([]); + 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: ( - <> + {t('Query')} - + ), - 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!')); diff --git a/superset-frontend/src/SqlLab/reducers/index.js b/superset-frontend/src/pages/SqlLab/LocationContext.tsx similarity index 56% rename from superset-frontend/src/SqlLab/reducers/index.js rename to superset-frontend/src/pages/SqlLab/LocationContext.tsx index 35c16ba2e1..a67b887c99 100644 --- a/superset-frontend/src/SqlLab/reducers/index.js +++ b/superset-frontend/src/pages/SqlLab/LocationContext.tsx @@ -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; }; -export default combineReducers(reducers); +export const locationContext = createContext({}); +const { Provider } = locationContext; + +const EMPTY_STATE: LocationState = {}; + +export const LocationProvider: React.FC = ({ + children, +}: { + children: React.ReactNode; +}) => { + const location = useLocation(); + return {children}; +}; + +export const useLocationState = () => useContext(locationContext); diff --git a/superset-frontend/src/pages/SqlLab/SqlLab.test.tsx b/superset-frontend/src/pages/SqlLab/SqlLab.test.tsx new file mode 100644 index 0000000000..0eec7156d1 --- /dev/null +++ b/superset-frontend/src/pages/SqlLab/SqlLab.test.tsx @@ -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', () => () => ( +
+)); + +test('is valid', () => { + expect(React.isValidElement()).toBe(true); +}); + +test('fetches initial data and renders', async () => { + expect(fetchMock.calls(sqlLabInitialStateApiRoute).length).toBe(0); + const storeWithSqlLab = createStore({}, reducers); + const { getByTestId } = render(, { + 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']), + ), + }), + ); +}); diff --git a/superset-frontend/src/pages/SqlLab/index.tsx b/superset-frontend/src/pages/SqlLab/index.tsx new file mode 100644 index 0000000000..e9f84f1b1d --- /dev/null +++ b/superset-frontend/src/pages/SqlLab/index.tsx @@ -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( + 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 ; + + if (isError && error?.message) { + dispatch(addDangerToast(error?.message)); + return null; + } + + return ( + +
+ + +
+
+ ); +} diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index b539ca126f..85f7c60252 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -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!')); diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index 197284d3ac..2e5b987e2e 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -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)) { diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index 7bad2ea875..dea99be2cf 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -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', diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 1cab4b1bf5..e84689994a 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -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", diff --git a/superset/jinja_context.py b/superset/jinja_context.py index 89f9c8ddcc..71ebf0d29a 100644 --- a/superset/jinja_context.py +++ b/superset/jinja_context.py @@ -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. diff --git a/superset/models/core.py b/superset/models/core.py index 332e5bb513..f6e4b972b4 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -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( diff --git a/superset/models/sql_lab.py b/superset/models/sql_lab.py index ffd19fb0e4..7e63e984df 100644 --- a/superset/models/sql_lab.py +++ b/superset/models/sql_lab.py @@ -408,7 +408,7 @@ class SavedQuery(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin): def pop_tab_link(self) -> Markup: return Markup( f""" - + """ @@ -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]: diff --git a/superset/sqllab/api.py b/superset/sqllab/api.py index d085174b5f..b3363e2e9a 100644 --- a/superset/sqllab/api.py +++ b/superset/sqllab/api.py @@ -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" diff --git a/superset/views/core.py b/superset/views/core.py index e67a255da2..95636de6ad 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -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") diff --git a/superset/views/sqllab.py b/superset/views/sqllab.py new file mode 100644 index 0000000000..708716511f --- /dev/null +++ b/superset/views/sqllab.py @@ -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() diff --git a/tests/integration_tests/core_tests.py b/tests/integration_tests/core_tests.py index 5f379e2c47..6d06e46fa3 100644 --- a/tests/integration_tests/core_tests.py +++ b/tests/integration_tests/core_tests.py @@ -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() diff --git a/tests/integration_tests/sqllab_tests.py b/tests/integration_tests/sqllab_tests.py index fbab4d98d2..3b8941e556 100644 --- a/tests/integration_tests/sqllab_tests.py +++ b/tests/integration_tests/sqllab_tests.py @@ -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)