diff --git a/superset-frontend/cypress-base/cypress/integration/sqllab/tabs.test.ts b/superset-frontend/cypress-base/cypress/integration/sqllab/tabs.test.ts
index 0e85664cb785..c7f1a23e47d2 100644
--- a/superset-frontend/cypress-base/cypress/integration/sqllab/tabs.test.ts
+++ b/superset-frontend/cypress-base/cypress/integration/sqllab/tabs.test.ts
@@ -30,8 +30,10 @@ describe('SqlLab query tabs', () => {
const initialUntitledCount = Math.max(
0,
...tabs
- .map((i, tabItem) =>
- Number(tabItem.textContent?.match(/Untitled Query (\d+)/)?.[1]),
+ .map(
+ (i, tabItem) =>
+ Number(tabItem.textContent?.match(/Untitled Query (\d+)/)?.[1]) ||
+ 0,
)
.toArray(),
);
diff --git a/superset-frontend/src/SqlLab/App.jsx b/superset-frontend/src/SqlLab/App.jsx
index 02a4df2a6f8d..39f784a5c57f 100644
--- a/superset-frontend/src/SqlLab/App.jsx
+++ b/superset-frontend/src/SqlLab/App.jsx
@@ -67,6 +67,9 @@ const sqlLabPersistStateConfig = {
...state[path],
queries: emptyQueryResults(state[path].queries),
queryEditors: clearQueryEditors(state[path].queryEditors),
+ unsavedQueryEditor: clearQueryEditors([
+ state[path].unsavedQueryEditor,
+ ])[0],
};
}
});
@@ -91,6 +94,12 @@ const sqlLabPersistStateConfig = {
const result = {
...initialState,
...persistedState,
+ sqlLab: {
+ ...(persistedState?.sqlLab || {}),
+ // Overwrite initialState over persistedState for sqlLab
+ // since a logic in getInitialState overrides the value from persistedState
+ ...initialState.sqlLab,
+ },
};
// Filter out any user data that may have been persisted in an older version.
// Get user from bootstrap data instead, every time
diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.js b/superset-frontend/src/SqlLab/actions/sqlLab.js
index 6391ce82c98e..8e397b075a18 100644
--- a/superset-frontend/src/SqlLab/actions/sqlLab.js
+++ b/superset-frontend/src/SqlLab/actions/sqlLab.js
@@ -123,6 +123,17 @@ const fieldConverter = mapping => obj =>
const convertQueryToServer = fieldConverter(queryServerMapping);
const convertQueryToClient = fieldConverter(queryClientMapping);
+export function getUpToDateQuery(rootState, queryEditor, key) {
+ const {
+ sqlLab: { unsavedQueryEditor },
+ } = rootState;
+ const id = key ?? queryEditor.id;
+ return {
+ ...queryEditor,
+ ...(id === unsavedQueryEditor.id && unsavedQueryEditor),
+ };
+}
+
export function resetState() {
return { type: RESET_STATE };
}
@@ -167,24 +178,26 @@ export function scheduleQuery(query) {
);
}
-export function estimateQueryCost(query) {
- const { dbId, schema, sql, templateParams } = query;
- const endpoint =
- schema === null
- ? `/superset/estimate_query_cost/${dbId}/`
- : `/superset/estimate_query_cost/${dbId}/${schema}/`;
- return dispatch =>
- Promise.all([
- dispatch({ type: COST_ESTIMATE_STARTED, query }),
+export function estimateQueryCost(queryEditor) {
+ return (dispatch, getState) => {
+ const { dbId, schema, sql, selectedText, templateParams } =
+ getUpToDateQuery(getState(), queryEditor);
+ const requestSql = selectedText || sql;
+ const endpoint =
+ schema === null
+ ? `/superset/estimate_query_cost/${dbId}/`
+ : `/superset/estimate_query_cost/${dbId}/${schema}/`;
+ return Promise.all([
+ dispatch({ type: COST_ESTIMATE_STARTED, query: queryEditor }),
SupersetClient.post({
endpoint,
postPayload: {
- sql,
+ sql: requestSql,
templateParams: JSON.parse(templateParams || '{}'),
},
})
.then(({ json }) =>
- dispatch({ type: COST_ESTIMATE_RETURNED, query, json }),
+ dispatch({ type: COST_ESTIMATE_RETURNED, query: queryEditor, json }),
)
.catch(response =>
getClientErrorObject(response).then(error => {
@@ -194,12 +207,13 @@ export function estimateQueryCost(query) {
t('Failed at retrieving results');
return dispatch({
type: COST_ESTIMATE_FAILED,
- query,
+ query: queryEditor,
error: message,
});
}),
),
]);
+ };
}
export function startQuery(query) {
@@ -357,6 +371,34 @@ export function runQuery(query) {
};
}
+export function runQueryFromSqlEditor(
+ database,
+ queryEditor,
+ defaultQueryLimit,
+ tempTable,
+ ctas,
+ ctasMethod,
+) {
+ return function (dispatch, getState) {
+ const qe = getUpToDateQuery(getState(), queryEditor, queryEditor.id);
+ const query = {
+ dbId: qe.dbId,
+ sql: qe.selectedText || qe.sql,
+ sqlEditorId: qe.id,
+ tab: qe.name,
+ schema: qe.schema,
+ tempTable,
+ templateParams: qe.templateParams,
+ queryLimit: qe.queryLimit || defaultQueryLimit,
+ runAsync: database ? database.allow_run_async : false,
+ ctas,
+ ctas_method: ctasMethod,
+ updateTabState: !qe.selectedText,
+ };
+ dispatch(runQuery(query));
+ };
+}
+
export function reRunQuery(query) {
// run Query with a new id
return function (dispatch) {
@@ -364,8 +406,23 @@ export function reRunQuery(query) {
};
}
-export function validateQuery(query) {
- return function (dispatch) {
+export function validateQuery(queryEditor, sql) {
+ return function (dispatch, getState) {
+ const {
+ sqlLab: { unsavedQueryEditor },
+ } = getState();
+ const qe = {
+ ...queryEditor,
+ ...(queryEditor.id === unsavedQueryEditor.id && unsavedQueryEditor),
+ };
+
+ const query = {
+ dbId: qe.dbId,
+ sql,
+ sqlEditorId: qe.id,
+ schema: qe.schema,
+ templateParams: qe.templateParams,
+ };
dispatch(startQueryValidation(query));
const postPayload = {
@@ -620,6 +677,7 @@ export function switchQueryEditor(queryEditor, displayLimit) {
return function (dispatch) {
if (
isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE) &&
+ queryEditor &&
!queryEditor.loaded
) {
SupersetClient.get({
@@ -723,6 +781,17 @@ export function removeQueryEditor(queryEditor) {
};
}
+export function removeAllOtherQueryEditors(queryEditor) {
+ return function (dispatch, getState) {
+ const { sqlLab } = getState();
+ sqlLab.queryEditors?.forEach(otherQueryEditor => {
+ if (otherQueryEditor.id !== queryEditor.id) {
+ dispatch(removeQueryEditor(otherQueryEditor));
+ }
+ });
+ };
+}
+
export function removeQuery(query) {
return function (dispatch) {
const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE)
@@ -921,8 +990,9 @@ export function queryEditorSetSql(queryEditor, sql) {
return { type: QUERY_EDITOR_SET_SQL, queryEditor, sql };
}
-export function queryEditorSetAndSaveSql(queryEditor, sql) {
- return function (dispatch) {
+export function queryEditorSetAndSaveSql(targetQueryEditor, sql) {
+ return function (dispatch, getState) {
+ const queryEditor = getUpToDateQuery(getState(), targetQueryEditor);
// saved query and set tab state use this action
dispatch(queryEditorSetSql(queryEditor, sql));
if (isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE)) {
diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.test.js b/superset-frontend/src/SqlLab/actions/sqlLab.test.js
index 1c4509b3a1ad..4dadb9ea89f8 100644
--- a/superset-frontend/src/SqlLab/actions/sqlLab.test.js
+++ b/superset-frontend/src/SqlLab/actions/sqlLab.test.js
@@ -24,7 +24,7 @@ import thunk from 'redux-thunk';
import shortid from 'shortid';
import * as featureFlags from 'src/featureFlags';
import * as actions from 'src/SqlLab/actions/sqlLab';
-import { defaultQueryEditor, query } from '../fixtures';
+import { defaultQueryEditor, query, initialState } from 'src/SqlLab/fixtures';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
@@ -32,14 +32,13 @@ const mockStore = configureMockStore(middlewares);
describe('async actions', () => {
const mockBigNumber = '9223372036854775807';
const queryEditor = {
+ ...defaultQueryEditor,
id: 'abcd',
autorun: false,
- dbId: null,
latestQueryId: null,
- selectedText: null,
sql: 'SELECT *\nFROM\nWHERE',
name: 'Untitled Query 1',
- schemaOptions: [{ value: 'main', label: 'main', name: 'main' }],
+ schemaOptions: [{ value: 'main', label: 'main', title: 'main' }],
};
let dispatch;
@@ -65,20 +64,20 @@ describe('async actions', () => {
const makeRequest = () => {
const request = actions.saveQuery(query);
- return request(dispatch);
+ return request(dispatch, () => initialState);
};
it('posts to the correct url', () => {
expect.assertions(1);
- const store = mockStore({});
+ const store = mockStore(initialState);
return store.dispatch(actions.saveQuery(query)).then(() => {
expect(fetchMock.calls(saveQueryEndpoint)).toHaveLength(1);
});
});
it('posts the correct query object', () => {
- const store = mockStore({});
+ const store = mockStore(initialState);
return store.dispatch(actions.saveQuery(query)).then(() => {
const call = fetchMock.calls(saveQueryEndpoint)[0];
const formData = call[1].body;
@@ -107,7 +106,7 @@ describe('async actions', () => {
it('onSave calls QUERY_EDITOR_SAVED and QUERY_EDITOR_SET_TITLE', () => {
expect.assertions(1);
- const store = mockStore({});
+ const store = mockStore(initialState);
const expectedActionTypes = [
actions.QUERY_EDITOR_SAVED,
actions.QUERY_EDITOR_SET_TITLE,
@@ -191,7 +190,7 @@ describe('async actions', () => {
describe('runQuery without query params', () => {
const makeRequest = () => {
const request = actions.runQuery(query);
- return request(dispatch);
+ return request(dispatch, () => initialState);
};
it('makes the fetch request', () => {
@@ -224,7 +223,9 @@ describe('async actions', () => {
const store = mockStore({});
const expectedActionTypes = [actions.START_QUERY, actions.QUERY_SUCCESS];
- return store.dispatch(actions.runQuery(query)).then(() => {
+ const { dispatch } = store;
+ const request = actions.runQuery(query);
+ return request(dispatch, () => initialState).then(() => {
expect(store.getActions().map(a => a.type)).toEqual(
expectedActionTypes,
);
@@ -242,7 +243,9 @@ describe('async actions', () => {
const store = mockStore({});
const expectedActionTypes = [actions.START_QUERY, actions.QUERY_FAILED];
- return store.dispatch(actions.runQuery(query)).then(() => {
+ const { dispatch } = store;
+ const request = actions.runQuery(query);
+ return request(dispatch, () => initialState).then(() => {
expect(store.getActions().map(a => a.type)).toEqual(
expectedActionTypes,
);
@@ -265,15 +268,19 @@ describe('async actions', () => {
const makeRequest = () => {
const request = actions.runQuery(query);
- return request(dispatch);
+ return request(dispatch, () => initialState);
};
- it('makes the fetch request', () =>
- makeRequest().then(() => {
- expect(
- fetchMock.calls('glob:*/superset/sql_json/?foo=bar'),
- ).toHaveLength(1);
- }));
+ it('makes the fetch request', async () => {
+ const runQueryEndpointWithParams = 'glob:*/superset/sql_json/?foo=bar';
+ fetchMock.post(
+ runQueryEndpointWithParams,
+ `{ "data": ${mockBigNumber} }`,
+ );
+ await makeRequest().then(() => {
+ expect(fetchMock.calls(runQueryEndpointWithParams)).toHaveLength(1);
+ });
+ });
});
describe('reRunQuery', () => {
@@ -291,10 +298,12 @@ describe('async actions', () => {
sqlLab: {
tabHistory: [id],
queryEditors: [{ id, name: 'Dummy query editor' }],
+ unsavedQueryEditor: {},
},
};
const store = mockStore(state);
- store.dispatch(actions.reRunQuery(query));
+ const request = actions.reRunQuery(query);
+ request(store.dispatch, store.getState);
expect(store.getActions()[0].query.id).toEqual('abcd');
});
});
@@ -351,6 +360,7 @@ describe('async actions', () => {
sqlLab: {
tabHistory: [id],
queryEditors: [{ id, name: 'Dummy query editor' }],
+ unsavedQueryEditor: {},
},
};
const store = mockStore(state);
@@ -369,11 +379,10 @@ describe('async actions', () => {
},
},
];
- return store
- .dispatch(actions.cloneQueryToNewTab(query, true))
- .then(() => {
- expect(store.getActions()).toEqual(expectedActions);
- });
+ const request = actions.cloneQueryToNewTab(query, true);
+ return request(store.dispatch, store.getState).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
});
});
@@ -389,18 +398,17 @@ describe('async actions', () => {
it('creates new query editor', () => {
expect.assertions(1);
- const store = mockStore({});
+ const store = mockStore(initialState);
const expectedActions = [
{
type: actions.ADD_QUERY_EDITOR,
queryEditor,
},
];
- return store
- .dispatch(actions.addQueryEditor(defaultQueryEditor))
- .then(() => {
- expect(store.getActions()).toEqual(expectedActions);
- });
+ const request = actions.addQueryEditor(defaultQueryEditor);
+ return request(store.dispatch, store.getState).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
});
});
@@ -648,14 +656,12 @@ describe('async actions', () => {
it('updates the tab state in the backend', () => {
expect.assertions(2);
- const store = mockStore({});
-
- return store
- .dispatch(actions.queryEditorSetAndSaveSql(queryEditor, sql))
- .then(() => {
- expect(store.getActions()).toEqual(expectedActions);
- expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1);
- });
+ const store = mockStore(initialState);
+ const request = actions.queryEditorSetAndSaveSql(queryEditor, sql);
+ return request(store.dispatch, store.getState).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1);
+ });
});
});
describe('with backend persistence flag off', () => {
@@ -666,9 +672,9 @@ describe('async actions', () => {
feature => !(feature === 'SQLLAB_BACKEND_PERSISTENCE'),
);
- const store = mockStore({});
-
- store.dispatch(actions.queryEditorSetAndSaveSql(queryEditor, sql));
+ const store = mockStore(initialState);
+ const request = actions.queryEditorSetAndSaveSql(queryEditor, sql);
+ request(store.dispatch, store.getState);
expect(store.getActions()).toEqual(expectedActions);
expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0);
@@ -770,7 +776,7 @@ describe('async actions', () => {
const database = { disable_data_preview: false, id: 1 };
const tableName = 'table';
const schemaName = 'schema';
- const store = mockStore({});
+ const store = mockStore(initialState);
const expectedActionTypes = [
actions.MERGE_TABLE, // addTable
actions.MERGE_TABLE, // getTableMetadata
@@ -780,20 +786,24 @@ describe('async actions', () => {
actions.MERGE_TABLE, // addTable
actions.QUERY_SUCCESS, // querySuccess
];
- return store
- .dispatch(actions.addTable(query, database, tableName, schemaName))
- .then(() => {
- expect(store.getActions().map(a => a.type)).toEqual(
- expectedActionTypes,
- );
- expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(1);
- expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1);
- expect(fetchMock.calls(getExtraTableMetadataEndpoint)).toHaveLength(
- 1,
- );
- // tab state is not updated, since the query is a data preview
- expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0);
- });
+ const request = actions.addTable(
+ query,
+ database,
+ tableName,
+ schemaName,
+ );
+ return request(store.dispatch, store.getState).then(() => {
+ expect(store.getActions().map(a => a.type)).toEqual(
+ expectedActionTypes,
+ );
+ expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(1);
+ expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1);
+ expect(fetchMock.calls(getExtraTableMetadataEndpoint)).toHaveLength(
+ 1,
+ );
+ // tab state is not updated, since the query is a data preview
+ expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0);
+ });
});
});
diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/AceEditorWrapper.test.tsx b/superset-frontend/src/SqlLab/components/AceEditorWrapper/AceEditorWrapper.test.tsx
new file mode 100644
index 000000000000..6bfefc0a3db4
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/AceEditorWrapper.test.tsx
@@ -0,0 +1,129 @@
+/**
+ * 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 configureStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+import { render, waitFor } from 'spec/helpers/testing-library';
+import { QueryEditor } from 'src/SqlLab/types';
+import { Store } from 'redux';
+import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
+import {
+ queryEditorSetSelectedText,
+ queryEditorSetFunctionNames,
+ addTable,
+} from 'src/SqlLab/actions/sqlLab';
+import AceEditorWrapper from 'src/SqlLab/components/AceEditorWrapper';
+import { AsyncAceEditorProps } from 'src/components/AsyncAceEditor';
+
+const middlewares = [thunk];
+const mockStore = configureStore(middlewares);
+
+jest.mock('src/components/Select', () => () => (
+
+));
+jest.mock('src/components/Select/Select', () => () => (
+
+));
+jest.mock('src/components/Select/AsyncSelect', () => () => (
+
+));
+
+jest.mock('src/components/AsyncAceEditor', () => ({
+ FullSQLEditor: (props: AsyncAceEditorProps) => (
+ {JSON.stringify(props)}
+ ),
+}));
+
+const setup = (queryEditor: QueryEditor, store?: Store) =>
+ render(
+ ,
+ {
+ useRedux: true,
+ ...(store && { store }),
+ },
+ );
+
+describe('AceEditorWrapper', () => {
+ it('renders ace editor including sql value', async () => {
+ const { getByTestId } = setup(defaultQueryEditor, mockStore(initialState));
+ await waitFor(() => expect(getByTestId('react-ace')).toBeInTheDocument());
+
+ expect(getByTestId('react-ace')).toHaveTextContent(
+ JSON.stringify({ value: defaultQueryEditor.sql }).slice(1, -1),
+ );
+ });
+
+ it('renders sql from unsaved change', () => {
+ const expectedSql = 'SELECT updated_column\nFROM updated_table\nWHERE';
+ const { getByTestId } = setup(
+ defaultQueryEditor,
+ mockStore({
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ unsavedQueryEditor: {
+ id: defaultQueryEditor.id,
+ sql: expectedSql,
+ },
+ },
+ }),
+ );
+
+ expect(getByTestId('react-ace')).toHaveTextContent(
+ JSON.stringify({ value: expectedSql }).slice(1, -1),
+ );
+ });
+
+ it('renders current sql for unrelated unsaved changes', () => {
+ const expectedSql = 'SELECT updated_column\nFROM updated_table\nWHERE';
+ const { getByTestId } = setup(
+ defaultQueryEditor,
+ mockStore({
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ unsavedQueryEditor: {
+ id: `${defaultQueryEditor.id}-other`,
+ sql: expectedSql,
+ },
+ },
+ }),
+ );
+
+ expect(getByTestId('react-ace')).not.toHaveTextContent(
+ JSON.stringify({ value: expectedSql }).slice(1, -1),
+ );
+ expect(getByTestId('react-ace')).toHaveTextContent(
+ JSON.stringify({ value: defaultQueryEditor.sql }).slice(1, -1),
+ );
+ });
+});
diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
index 53ec3f808a62..6b70be228bf3 100644
--- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
+++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
import React from 'react';
+import { connect } from 'react-redux';
import { areArraysShallowEqual } from 'src/reduxUtils';
import sqlKeywords from 'src/SqlLab/utils/sqlKeywords';
import {
@@ -30,7 +31,7 @@ import {
AceCompleterKeyword,
FullSQLEditor as AceEditor,
} from 'src/components/AsyncAceEditor';
-import { QueryEditor } from 'src/SqlLab/types';
+import { QueryEditor, SchemaOption, SqlLabRootState } from 'src/SqlLab/types';
type HotKey = {
key: string;
@@ -39,7 +40,13 @@ type HotKey = {
func: () => void;
};
-interface Props {
+type OwnProps = {
+ queryEditor: QueryEditor;
+ extendedTables: Array<{ name: string; columns: any[] }>;
+ autocomplete: boolean;
+ onChange: (sql: string) => void;
+ onBlur: (sql: string) => void;
+ database: any;
actions: {
queryEditorSetSelectedText: (edit: any, text: null | string) => void;
queryEditorSetFunctionNames: (queryEditor: object, dbId: number) => void;
@@ -50,19 +57,19 @@ interface Props {
schema: any,
) => void;
};
- autocomplete: boolean;
- onBlur: (sql: string) => void;
+ hotkeys: HotKey[];
+ height: string;
+};
+
+type ReduxProps = {
+ queryEditor: QueryEditor;
sql: string;
- database: any;
- schemas: any[];
+ schemas: SchemaOption[];
tables: any[];
functionNames: string[];
- extendedTables: Array<{ name: string; columns: any[] }>;
- queryEditor: QueryEditor;
- height: string;
- hotkeys: HotKey[];
- onChange: (sql: string) => void;
-}
+};
+
+type Props = ReduxProps & OwnProps;
interface State {
sql: string;
@@ -286,4 +293,22 @@ class AceEditorWrapper extends React.PureComponent {
}
}
-export default AceEditorWrapper;
+function mapStateToProps(
+ { sqlLab: { unsavedQueryEditor } }: SqlLabRootState,
+ { queryEditor }: OwnProps,
+) {
+ const currentQueryEditor = {
+ ...queryEditor,
+ ...(queryEditor.id === unsavedQueryEditor.id && unsavedQueryEditor),
+ };
+ return {
+ queryEditor: currentQueryEditor,
+ sql: currentQueryEditor.sql,
+ schemas: currentQueryEditor.schemaOptions || [],
+ tables: currentQueryEditor.tableOptions,
+ functionNames: currentQueryEditor.functionNames,
+ };
+}
+export default connect(mapStateToProps)(
+ AceEditorWrapper,
+);
diff --git a/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/EstimateQueryCostButton.test.tsx b/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/EstimateQueryCostButton.test.tsx
new file mode 100644
index 000000000000..5a8eaeaa11e3
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/EstimateQueryCostButton.test.tsx
@@ -0,0 +1,93 @@
+/**
+ * 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 configureStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+import { render } from 'spec/helpers/testing-library';
+import { Store } from 'redux';
+import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
+
+import EstimateQueryCostButton, {
+ EstimateQueryCostButtonProps,
+} from 'src/SqlLab/components/EstimateQueryCostButton';
+
+const middlewares = [thunk];
+const mockStore = configureStore(middlewares);
+
+jest.mock('src/components/Select', () => () => (
+
+));
+jest.mock('src/components/Select/Select', () => () => (
+
+));
+jest.mock('src/components/Select/AsyncSelect', () => () => (
+
+));
+
+const setup = (props: Partial, store?: Store) =>
+ render(
+ ,
+ {
+ useRedux: true,
+ ...(store && { store }),
+ },
+ );
+
+describe('EstimateQueryCostButton', () => {
+ it('renders EstimateQueryCostButton', async () => {
+ const { queryByText } = setup({}, mockStore(initialState));
+
+ expect(queryByText('Estimate cost')).toBeTruthy();
+ });
+
+ it('renders label for selected query', async () => {
+ const queryEditorWithSelectedText = {
+ ...defaultQueryEditor,
+ selectedText: 'SELECT',
+ };
+ const { queryByText } = setup(
+ { queryEditor: queryEditorWithSelectedText },
+ mockStore(initialState),
+ );
+
+ expect(queryByText('Estimate selected query cost')).toBeTruthy();
+ });
+
+ it('renders label for selected query from unsaved', async () => {
+ const { queryByText } = setup(
+ {},
+ mockStore({
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ unsavedQueryEditor: {
+ id: defaultQueryEditor.id,
+ selectedText: 'SELECT',
+ },
+ },
+ }),
+ );
+
+ expect(queryByText('Estimate selected query cost')).toBeTruthy();
+ });
+});
diff --git a/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/index.tsx b/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/index.tsx
index d7f2d7dd6dd4..dc2c23c40626 100644
--- a/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/index.tsx
+++ b/superset-frontend/src/SqlLab/components/EstimateQueryCostButton/index.tsx
@@ -24,23 +24,37 @@ import Button from 'src/components/Button';
import Loading from 'src/components/Loading';
import ModalTrigger from 'src/components/ModalTrigger';
import { EmptyWrapperType } from 'src/components/TableView/TableView';
+import {
+ SqlLabRootState,
+ QueryCostEstimate,
+ QueryEditor,
+} from 'src/SqlLab/types';
+import { getUpToDateQuery } from 'src/SqlLab/actions/sqlLab';
+import { useSelector } from 'react-redux';
-interface EstimateQueryCostButtonProps {
+export interface EstimateQueryCostButtonProps {
getEstimate: Function;
- queryCostEstimate: Record;
- selectedText?: string;
+ queryEditor: QueryEditor;
tooltip?: string;
disabled?: boolean;
}
const EstimateQueryCostButton = ({
getEstimate,
- queryCostEstimate = {},
- selectedText,
+ queryEditor,
tooltip = '',
disabled = false,
}: EstimateQueryCostButtonProps) => {
- const { cost } = queryCostEstimate;
+ const queryCostEstimate = useSelector<
+ SqlLabRootState,
+ QueryCostEstimate | undefined
+ >(state => state.sqlLab.queryCostEstimates?.[queryEditor.id]);
+ const selectedText = useSelector(
+ rootState =>
+ (getUpToDateQuery(rootState, queryEditor) as unknown as QueryEditor)
+ .selectedText,
+ );
+ const { cost } = queryCostEstimate || {};
const tableData = useMemo(() => (Array.isArray(cost) ? cost : []), [cost]);
const columns = useMemo(
() =>
@@ -57,16 +71,16 @@ const EstimateQueryCostButton = ({
};
const renderModalBody = () => {
- if (queryCostEstimate.error !== null) {
+ if (queryCostEstimate?.error) {
return (
);
}
- if (queryCostEstimate.completed) {
+ if (queryCostEstimate?.completed) {
return (
() => (
+
+));
+jest.mock('src/components/Select/Select', () => () => (
+
+));
+jest.mock('src/components/Select/AsyncSelect', () => () => (
+
+));
+jest.mock('src/components/Icons/Icon', () => () => (
+
+));
+
+const defaultQueryLimit = 100;
+
+const setup = (props?: Partial, store?: Store) =>
+ render(
+ ,
+ {
+ useRedux: true,
+ ...(store && { store }),
+ },
+ );
+
+describe('QueryLimitSelect', () => {
+ it('renders current query limit size', () => {
+ const queryLimit = 10;
+ const { getByText } = setup(
+ {
+ queryEditor: {
+ ...defaultQueryEditor,
+ queryLimit,
+ },
+ },
+ mockStore(initialState),
+ );
+ expect(getByText(queryLimit)).toBeInTheDocument();
+ });
+
+ it('renders default query limit for initial queryEditor', () => {
+ const { getByText } = setup({}, mockStore(initialState));
+ expect(getByText(defaultQueryLimit)).toBeInTheDocument();
+ });
+
+ it('renders queryLimit from unsavedQueryEditor', () => {
+ const queryLimit = 10000;
+ const { getByText } = setup(
+ {},
+ mockStore({
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ unsavedQueryEditor: {
+ id: defaultQueryEditor.id,
+ queryLimit,
+ },
+ },
+ }),
+ );
+ expect(getByText(convertToNumWithSpaces(queryLimit))).toBeInTheDocument();
+ });
+
+ it('renders dropdown select', async () => {
+ const { baseElement, getByRole } = setup({}, mockStore(initialState));
+ const dropdown = baseElement.getElementsByClassName(
+ 'ant-dropdown-trigger',
+ )[0];
+
+ userEvent.click(dropdown);
+ await waitFor(() => expect(getByRole('menu')).toBeInTheDocument());
+ });
+
+ it('dispatches QUERY_EDITOR_SET_QUERY_LIMIT action on dropdown menu click', async () => {
+ const store = mockStore(initialState);
+ const expectedIndex = 1;
+ const { baseElement, getAllByRole, getByRole } = setup({}, store);
+ const dropdown = baseElement.getElementsByClassName(
+ 'ant-dropdown-trigger',
+ )[0];
+
+ userEvent.click(dropdown);
+ await waitFor(() => expect(getByRole('menu')).toBeInTheDocument());
+
+ const menu = getAllByRole('menuitem')[expectedIndex];
+ expect(store.getActions()).toEqual([]);
+ fireEvent.click(menu);
+ await waitFor(() =>
+ expect(store.getActions()).toEqual([
+ {
+ type: 'QUERY_EDITOR_SET_QUERY_LIMIT',
+ queryLimit: LIMIT_DROPDOWN[expectedIndex],
+ queryEditor: defaultQueryEditor,
+ },
+ ]),
+ );
+ });
+});
diff --git a/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx b/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx
new file mode 100644
index 000000000000..f438ebc59edc
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/QueryLimitSelect/index.tsx
@@ -0,0 +1,118 @@
+/**
+ * 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 { useSelector, useDispatch } from 'react-redux';
+import { styled, useTheme } from '@superset-ui/core';
+import { AntdDropdown } from 'src/components';
+import { Menu } from 'src/components/Menu';
+import Icons from 'src/components/Icons';
+import { SqlLabRootState, QueryEditor } from 'src/SqlLab/types';
+import { queryEditorSetQueryLimit } from 'src/SqlLab/actions/sqlLab';
+
+export interface QueryLimitSelectProps {
+ queryEditor: QueryEditor;
+ maxRow: number;
+ defaultQueryLimit: number;
+}
+
+export const LIMIT_DROPDOWN = [10, 100, 1000, 10000, 100000];
+
+export function convertToNumWithSpaces(num: number) {
+ return num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1 ');
+}
+
+const LimitSelectStyled = styled.span`
+ ${({ theme }) => `
+ .ant-dropdown-trigger {
+ align-items: center;
+ color: ${theme.colors.grayscale.dark2};
+ display: flex;
+ font-size: 12px;
+ margin-right: ${theme.gridUnit * 2}px;
+ text-decoration: none;
+ border: 0;
+ background: transparent;
+ span {
+ display: inline-block;
+ margin-right: ${theme.gridUnit * 2}px;
+ &:last-of-type: {
+ margin-right: ${theme.gridUnit * 4}px;
+ }
+ }
+ }
+ `}
+`;
+
+function renderQueryLimit(
+ maxRow: number,
+ setQueryLimit: (limit: number) => void,
+) {
+ // Adding SQL_MAX_ROW value to dropdown
+ LIMIT_DROPDOWN.push(maxRow);
+
+ return (
+
+ );
+}
+
+const QueryLimitSelect = ({
+ queryEditor,
+ maxRow,
+ defaultQueryLimit,
+}: QueryLimitSelectProps) => {
+ const queryLimit = useSelector(
+ ({ sqlLab: { unsavedQueryEditor } }) => {
+ const updatedQueryEditor = {
+ ...queryEditor,
+ ...(unsavedQueryEditor.id === queryEditor.id && unsavedQueryEditor),
+ };
+ return updatedQueryEditor.queryLimit || defaultQueryLimit;
+ },
+ );
+ const dispatch = useDispatch();
+ const setQueryLimit = (updatedQueryLimit: number) =>
+ dispatch(queryEditorSetQueryLimit(queryEditor, updatedQueryLimit));
+ const theme = useTheme();
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default QueryLimitSelect;
diff --git a/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.jsx b/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.jsx
deleted file mode 100644
index 823f10741ac0..000000000000
--- a/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.jsx
+++ /dev/null
@@ -1,53 +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 { mount } from 'enzyme';
-import { supersetTheme, ThemeProvider } from '@superset-ui/core';
-import RunQueryActionButton from 'src/SqlLab/components/RunQueryActionButton';
-import Button from 'src/components/Button';
-
-describe('RunQueryActionButton', () => {
- let wrapper;
- const defaultProps = {
- allowAsync: false,
- dbId: 1,
- queryState: 'pending',
- runQuery: () => {}, // eslint-disable-line
- selectedText: null,
- stopQuery: () => {}, // eslint-disable-line
- sql: '',
- };
-
- beforeEach(() => {
- wrapper = mount(, {
- wrappingComponent: ThemeProvider,
- wrappingComponentProps: { theme: supersetTheme },
- });
- });
-
- it('is a valid react element', () => {
- expect(
- React.isValidElement(),
- ).toBe(true);
- });
-
- it('renders a single Button', () => {
- expect(wrapper.find(Button)).toExist();
- });
-});
diff --git a/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.tsx b/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.tsx
new file mode 100644
index 000000000000..780298968995
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/RunQueryActionButton/RunQueryActionButton.test.tsx
@@ -0,0 +1,151 @@
+/**
+ * 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 configureStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+import { Store } from 'redux';
+
+import { render, fireEvent, waitFor } from 'spec/helpers/testing-library';
+import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
+import RunQueryActionButton, {
+ Props,
+} from 'src/SqlLab/components/RunQueryActionButton';
+
+const middlewares = [thunk];
+const mockStore = configureStore(middlewares);
+
+jest.mock('src/components/Select', () => () => (
+
+));
+jest.mock('src/components/Select/Select', () => () => (
+
+));
+jest.mock('src/components/Select/AsyncSelect', () => () => (
+
+));
+
+const defaultProps = {
+ queryEditor: defaultQueryEditor,
+ allowAsync: false,
+ dbId: 1,
+ queryState: 'ready',
+ runQuery: jest.fn(),
+ selectedText: null,
+ stopQuery: jest.fn(),
+ overlayCreateAsMenu: null,
+};
+
+const setup = (props?: Partial, store?: Store) =>
+ render(, {
+ useRedux: true,
+ ...(store && { store }),
+ });
+
+describe('RunQueryActionButton', () => {
+ beforeEach(() => {
+ defaultProps.runQuery.mockReset();
+ defaultProps.stopQuery.mockReset();
+ });
+
+ it('renders a single Button', () => {
+ const { getByRole } = setup({}, mockStore(initialState));
+ expect(getByRole('button')).toBeInTheDocument();
+ });
+
+ it('renders a label for Run Query', () => {
+ const { getByText } = setup({}, mockStore(initialState));
+ expect(getByText('Run')).toBeInTheDocument();
+ });
+
+ it('renders a label for Selected Query', () => {
+ const { getByText } = setup(
+ {},
+ mockStore({
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ unsavedQueryEditor: {
+ id: defaultQueryEditor.id,
+ selectedText: 'FROM',
+ },
+ },
+ }),
+ );
+ expect(getByText('Run selection')).toBeInTheDocument();
+ });
+
+ it('disable button when sql from unsaved changes is empty', () => {
+ const { getByRole } = setup(
+ {},
+ mockStore({
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ unsavedQueryEditor: {
+ id: defaultQueryEditor.id,
+ sql: '',
+ },
+ },
+ }),
+ );
+ const button = getByRole('button');
+ expect(button).toBeDisabled();
+ });
+
+ it('enable default button for unrelated unsaved changes', () => {
+ const { getByRole } = setup(
+ {},
+ mockStore({
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ unsavedQueryEditor: {
+ id: `${defaultQueryEditor.id}-other`,
+ sql: '',
+ },
+ },
+ }),
+ );
+ const button = getByRole('button');
+ expect(button).toBeEnabled();
+ });
+
+ it('dispatch runQuery on click', async () => {
+ const { getByRole } = setup({}, mockStore(initialState));
+ const button = getByRole('button');
+ expect(defaultProps.runQuery).toHaveBeenCalledTimes(0);
+ fireEvent.click(button);
+ await waitFor(() => expect(defaultProps.runQuery).toHaveBeenCalledTimes(1));
+ });
+
+ describe('on running state', () => {
+ it('dispatch stopQuery on click', async () => {
+ const { getByRole } = setup(
+ { queryState: 'running' },
+ mockStore(initialState),
+ );
+ const button = getByRole('button');
+ expect(defaultProps.stopQuery).toHaveBeenCalledTimes(0);
+ fireEvent.click(button);
+ await waitFor(() =>
+ expect(defaultProps.stopQuery).toHaveBeenCalledTimes(1),
+ );
+ });
+ });
+});
diff --git a/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx b/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx
index 19bf77875628..5cc453f5ee93 100644
--- a/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx
+++ b/superset-frontend/src/SqlLab/components/RunQueryActionButton/index.tsx
@@ -24,15 +24,20 @@ import Button from 'src/components/Button';
import Icons from 'src/components/Icons';
import { DropdownButton } from 'src/components/DropdownButton';
import { detectOS } from 'src/utils/common';
-import { QueryButtonProps } from 'src/SqlLab/types';
+import { shallowEqual, useSelector } from 'react-redux';
+import {
+ QueryEditor,
+ SqlLabRootState,
+ QueryButtonProps,
+} from 'src/SqlLab/types';
+import { getUpToDateQuery } from 'src/SqlLab/actions/sqlLab';
-interface Props {
+export interface Props {
+ queryEditor: QueryEditor;
allowAsync: boolean;
queryState?: string;
runQuery: (c?: boolean) => void;
- selectedText?: string;
stopQuery: () => void;
- sql: string;
overlayCreateAsMenu: typeof Menu | null;
}
@@ -83,16 +88,27 @@ const StyledButton = styled.span`
const RunQueryActionButton = ({
allowAsync = false,
+ queryEditor,
queryState,
- selectedText,
- sql = '',
overlayCreateAsMenu,
runQuery,
stopQuery,
}: Props) => {
const theme = useTheme();
-
const userOS = detectOS();
+ const { selectedText, sql } = useSelector<
+ SqlLabRootState,
+ Pick
+ >(rootState => {
+ const currentQueryEditor = getUpToDateQuery(
+ rootState,
+ queryEditor,
+ ) as unknown as QueryEditor;
+ return {
+ selectedText: currentQueryEditor.selectedText,
+ sql: currentQueryEditor.sql,
+ };
+ }, shallowEqual);
const shouldShowStopBtn =
!!queryState && ['running', 'pending'].indexOf(queryState) > -1;
@@ -101,7 +117,7 @@ const RunQueryActionButton = ({
? (DropdownButton as React.FC)
: Button;
- const isDisabled = !sql.trim();
+ const isDisabled = !sql || !sql.trim();
const stopButtonTooltipText = useMemo(
() =>
diff --git a/superset-frontend/src/SqlLab/components/SaveQuery/SaveQuery.test.jsx b/superset-frontend/src/SqlLab/components/SaveQuery/SaveQuery.test.jsx
index 94b7e2780ba1..2a5fcf3eb79f 100644
--- a/superset-frontend/src/SqlLab/components/SaveQuery/SaveQuery.test.jsx
+++ b/superset-frontend/src/SqlLab/components/SaveQuery/SaveQuery.test.jsx
@@ -17,18 +17,19 @@
* under the License.
*/
import React from 'react';
+import configureStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import SaveQuery from 'src/SqlLab/components/SaveQuery';
-import { databases } from 'src/SqlLab/fixtures';
+import { initialState, databases } from 'src/SqlLab/fixtures';
const mockedProps = {
- query: {
+ queryEditor: {
dbId: 1,
schema: 'main',
sql: 'SELECT * FROM t',
},
- defaultLabel: 'untitled',
animation: false,
database: databases.result[0],
onUpdate: () => {},
@@ -43,9 +44,15 @@ const splitSaveBtnProps = {
},
};
+const middlewares = [thunk];
+const mockStore = configureStore(middlewares);
+
describe('SavedQuery', () => {
it('renders a non-split save button when allows_virtual_table_explore is not enabled', () => {
- render(, { useRedux: true });
+ render(, {
+ useRedux: true,
+ store: mockStore(initialState),
+ });
const saveBtn = screen.getByRole('button', { name: /save/i });
@@ -53,7 +60,10 @@ describe('SavedQuery', () => {
});
it('renders a save query modal when user clicks save button', () => {
- render(, { useRedux: true });
+ render(, {
+ useRedux: true,
+ store: mockStore(initialState),
+ });
const saveBtn = screen.getByRole('button', { name: /save/i });
userEvent.click(saveBtn);
@@ -66,7 +76,10 @@ describe('SavedQuery', () => {
});
it('renders the save query modal UI', () => {
- render(, { useRedux: true });
+ render(, {
+ useRedux: true,
+ store: mockStore(initialState),
+ });
const saveBtn = screen.getByRole('button', { name: /save/i });
userEvent.click(saveBtn);
@@ -100,12 +113,15 @@ describe('SavedQuery', () => {
it('renders a "save as new" and "update" button if query already exists', () => {
const props = {
...mockedProps,
- query: {
+ queryEditor: {
...mockedProps.query,
remoteId: '42',
},
};
- render(, { useRedux: true });
+ render(, {
+ useRedux: true,
+ store: mockStore(initialState),
+ });
const saveBtn = screen.getByRole('button', { name: /save/i });
userEvent.click(saveBtn);
@@ -118,7 +134,10 @@ describe('SavedQuery', () => {
});
it('renders a split save button when allows_virtual_table_explore is enabled', async () => {
- render(, { useRedux: true });
+ render(, {
+ useRedux: true,
+ store: mockStore(initialState),
+ });
await waitFor(() => {
const saveBtn = screen.getByRole('button', { name: /save/i });
@@ -130,7 +149,10 @@ describe('SavedQuery', () => {
});
it('renders a save dataset modal when user clicks "save dataset" menu item', async () => {
- render(, { useRedux: true });
+ render(, {
+ useRedux: true,
+ store: mockStore(initialState),
+ });
await waitFor(() => {
const caretBtn = screen.getByRole('button', { name: /caret-down/i });
@@ -146,7 +168,10 @@ describe('SavedQuery', () => {
});
it('renders the save dataset modal UI', async () => {
- render(, { useRedux: true });
+ render(, {
+ useRedux: true,
+ store: mockStore(initialState),
+ });
await waitFor(() => {
const caretBtn = screen.getByRole('button', { name: /caret-down/i });
diff --git a/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx b/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx
index 554514f4db48..38cd5625ecd6 100644
--- a/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
import React, { useState, useEffect } from 'react';
+import { useSelector, shallowEqual } from 'react-redux';
import { Row, Col } from 'src/components';
import { Input, TextArea } from 'src/components/Input';
import { t, styled } from '@superset-ui/core';
@@ -25,12 +26,16 @@ import { Menu } from 'src/components/Menu';
import { Form, FormItem } from 'src/components/Form';
import Modal from 'src/components/Modal';
import SaveDatasetActionButton from 'src/SqlLab/components/SaveDatasetActionButton';
-import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
+import {
+ SaveDatasetModal,
+ ISaveableDatasource,
+} from 'src/SqlLab/components/SaveDatasetModal';
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
+import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
interface SaveQueryProps {
- query: QueryPayload;
- defaultLabel: string;
+ queryEditor: QueryEditor;
+ columns: ISaveableDatasource['columns'];
onSave: (arg0: QueryPayload) => void;
onUpdate: (arg0: QueryPayload) => void;
saveQueryWarning: string | null;
@@ -76,13 +81,22 @@ const Styles = styled.span`
`;
export default function SaveQuery({
- query,
- defaultLabel = t('Undefined'),
+ queryEditor,
onSave = () => {},
onUpdate,
saveQueryWarning = null,
database,
+ columns,
}: SaveQueryProps) {
+ const query = useSelector(
+ ({ sqlLab: { unsavedQueryEditor } }) => ({
+ ...queryEditor,
+ ...(queryEditor.id === unsavedQueryEditor.id && unsavedQueryEditor),
+ columns,
+ }),
+ shallowEqual,
+ );
+ const defaultLabel = query.name || query.description || t('Undefined');
const [description, setDescription] = useState(
query.description || '',
);
@@ -100,11 +114,12 @@ export default function SaveQuery({
);
- const queryPayload = () => ({
- ...query,
- name: label,
- description,
- });
+ const queryPayload = () =>
+ ({
+ ...query,
+ name: label,
+ description,
+ } as any as QueryPayload);
useEffect(() => {
if (!isSaved) setLabel(defaultLabel);
diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx
index d946c675cc8c..735264b9574f 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx
@@ -40,7 +40,12 @@ import {
} from 'src/SqlLab/actions/sqlLab';
import { EmptyStateBig } from 'src/components/EmptyState';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
-import { initialState, queries, table } from 'src/SqlLab/fixtures';
+import {
+ initialState,
+ queries,
+ table,
+ defaultQueryEditor,
+} from 'src/SqlLab/fixtures';
const MOCKED_SQL_EDITOR_HEIGHT = 500;
@@ -48,7 +53,31 @@ fetchMock.get('glob:*/api/v1/database/*', { result: [] });
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
-const store = mockStore(initialState);
+const store = mockStore({
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ databases: {
+ dbid1: {
+ allow_ctas: false,
+ allow_cvas: false,
+ allow_dml: false,
+ allow_file_upload: false,
+ allow_multi_schema_metadata_fetch: false,
+ allow_run_async: false,
+ backend: 'postgresql',
+ database_name: 'examples',
+ expose_in_sqllab: true,
+ force_ctas_schema: null,
+ id: 1,
+ },
+ },
+ unsavedQueryEditor: {
+ id: defaultQueryEditor.id,
+ dbId: 'dbid1',
+ },
+ },
+});
describe('SqlEditor', () => {
const mockedProps = {
@@ -57,21 +86,9 @@ describe('SqlEditor', () => {
queryEditorSetSelectedText,
queryEditorSetSchemaOptions,
addDangerToast: jest.fn(),
+ removeDataPreview: jest.fn(),
},
- database: {
- allow_ctas: false,
- allow_cvas: false,
- allow_dml: false,
- allow_file_upload: false,
- allow_multi_schema_metadata_fetch: false,
- allow_run_async: false,
- backend: 'postgresql',
- database_name: 'examples',
- expose_in_sqllab: true,
- force_ctas_schema: null,
- id: 1,
- },
- queryEditorId: initialState.sqlLab.queryEditors[0].id,
+ queryEditor: initialState.sqlLab.queryEditors[0],
latestQuery: queries[0],
tables: [table],
getHeight: () => '100px',
@@ -94,8 +111,8 @@ describe('SqlEditor', () => {
);
it('does not render SqlEditor if no db selected', () => {
- const database = {};
- const updatedProps = { ...mockedProps, database };
+ const queryEditor = initialState.sqlLab.queryEditors[1];
+ const updatedProps = { ...mockedProps, queryEditor };
const wrapper = buildWrapper(updatedProps);
expect(wrapper.find(EmptyStateBig)).toExist();
});
diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx
index bdda237158ae..d813b3b52d15 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx
@@ -43,10 +43,10 @@ import {
persistEditorHeight,
postStopQuery,
queryEditorSetAutorun,
- queryEditorSetQueryLimit,
queryEditorSetSql,
queryEditorSetAndSaveSql,
queryEditorSetTemplateParams,
+ runQueryFromSqlEditor,
runQuery,
saveQuery,
addSavedQueryToTabState,
@@ -79,8 +79,8 @@ import SqlEditorLeftBar from '../SqlEditorLeftBar';
import AceEditorWrapper from '../AceEditorWrapper';
import RunQueryActionButton from '../RunQueryActionButton';
import { newQueryTabName } from '../../utils/newQueryTabName';
+import QueryLimitSelect from '../QueryLimitSelect';
-const LIMIT_DROPDOWN = [10, 100, 1000, 10000, 100000];
const SQL_EDITOR_PADDING = 10;
const INITIAL_NORTH_PERCENT = 30;
const INITIAL_SOUTH_PERCENT = 70;
@@ -96,26 +96,6 @@ const validatorMap =
bootstrapData?.common?.conf?.SQL_VALIDATORS_BY_ENGINE || {};
const scheduledQueriesConf = bootstrapData?.common?.conf?.SCHEDULED_QUERIES;
-const LimitSelectStyled = styled.span`
- ${({ theme }) => `
- .ant-dropdown-trigger {
- align-items: center;
- color: ${theme.colors.grayscale.dark2};
- display: flex;
- font-size: 12px;
- margin-right: ${theme.gridUnit * 2}px;
- text-decoration: none;
- span {
- display: inline-block;
- margin-right: ${theme.gridUnit * 2}px;
- &:last-of-type: {
- margin-right: ${theme.gridUnit * 4}px;
- }
- }
- }
- `}
-`;
-
const StyledToolbar = styled.div`
padding: ${({ theme }) => theme.gridUnit * 2}px;
background: ${({ theme }) => theme.colors.grayscale.light5};
@@ -154,7 +134,7 @@ const propTypes = {
tables: PropTypes.array.isRequired,
editorQueries: PropTypes.array.isRequired,
dataPreviewQueries: PropTypes.array.isRequired,
- queryEditorId: PropTypes.string.isRequired,
+ queryEditor: PropTypes.object.isRequired,
hideLeftBar: PropTypes.bool,
defaultQueryLimit: PropTypes.number.isRequired,
maxRow: PropTypes.number.isRequired,
@@ -205,7 +185,6 @@ class SqlEditor extends React.PureComponent {
);
this.queryPane = this.queryPane.bind(this);
this.getHotkeyConfig = this.getHotkeyConfig.bind(this);
- this.renderQueryLimit = this.renderQueryLimit.bind(this);
this.getAceEditorAndSouthPaneHeights =
this.getAceEditorAndSouthPaneHeights.bind(this);
this.getSqlEditorHeight = this.getSqlEditorHeight.bind(this);
@@ -382,21 +361,10 @@ class SqlEditor extends React.PureComponent {
this.props.queryEditorSetAndSaveSql(this.props.queryEditor, sql);
}
- setQueryLimit(queryLimit) {
- this.props.queryEditorSetQueryLimit(this.props.queryEditor, queryLimit);
- }
-
getQueryCostEstimate() {
if (this.props.database) {
const qe = this.props.queryEditor;
- const query = {
- dbId: qe.dbId,
- sql: qe.selectedText ? qe.selectedText : this.props.queryEditor.sql,
- sqlEditorId: qe.id,
- schema: qe.schema,
- templateParams: qe.templateParams,
- };
- this.props.estimateQueryCost(query);
+ this.props.estimateQueryCost(qe);
}
}
@@ -425,16 +393,9 @@ class SqlEditor extends React.PureComponent {
}
requestValidation(sql) {
- if (this.props.database) {
- const qe = this.props.queryEditor;
- const query = {
- dbId: qe.dbId,
- sql,
- sqlEditorId: qe.id,
- schema: qe.schema,
- templateParams: qe.templateParams,
- };
- this.props.validateQuery(query);
+ const { database, queryEditor, validateQuery } = this.props;
+ if (database) {
+ validateQuery(queryEditor, sql);
}
}
@@ -458,25 +419,22 @@ class SqlEditor extends React.PureComponent {
}
startQuery(ctas = false, ctas_method = CtasEnum.TABLE) {
- const qe = this.props.queryEditor;
- const query = {
- dbId: qe.dbId,
- sql: qe.selectedText ? qe.selectedText : qe.sql,
- sqlEditorId: qe.id,
- tab: qe.name,
- schema: qe.schema,
- tempTable: ctas ? this.state.ctas : '',
- templateParams: qe.templateParams,
- queryLimit: qe.queryLimit || this.props.defaultQueryLimit,
- runAsync: this.props.database
- ? this.props.database.allow_run_async
- : false,
+ const {
+ database,
+ runQueryFromSqlEditor,
+ setActiveSouthPaneTab,
+ queryEditor,
+ defaultQueryLimit,
+ } = this.props;
+ runQueryFromSqlEditor(
+ database,
+ queryEditor,
+ defaultQueryLimit,
+ ctas ? this.state.ctas : '',
ctas,
ctas_method,
- updateTabState: !qe.selectedText,
- };
- this.props.runQuery(query);
- this.props.setActiveSouthPaneTab('Results');
+ );
+ setActiveSouthPaneTab('Results');
}
stopQuery() {
@@ -529,11 +487,7 @@ class SqlEditor extends React.PureComponent {
onBlur={this.setQueryEditorSql}
onChange={this.onSqlChanged}
queryEditor={this.props.queryEditor}
- sql={this.props.queryEditor.sql}
database={this.props.database}
- schemas={this.props.queryEditor.schemaOptions}
- tables={this.props.queryEditor.tableOptions}
- functionNames={this.props.queryEditor.functionNames}
extendedTables={this.props.tables}
height={`${aceEditorHeight}px`}
hotkeys={hotkeys}
@@ -577,7 +531,7 @@ class SqlEditor extends React.PureComponent {
onChange={params => {
this.props.actions.queryEditorSetTemplateParams(qe, params);
}}
- code={qe.templateParams}
+ queryEditor={qe}
/>
)}
@@ -599,25 +553,6 @@ class SqlEditor extends React.PureComponent {
);
}
- renderQueryLimit() {
- // Adding SQL_MAX_ROW value to dropdown
- const { maxRow } = this.props;
- LIMIT_DROPDOWN.push(maxRow);
-
- return (
-
- );
- }
-
async saveQuery(query) {
const { queryEditor: qe, actions } = this.props;
const savedQuery = await actions.saveQuery(query);
@@ -673,11 +608,10 @@ class SqlEditor extends React.PureComponent {
? this.props.database.allow_run_async
: false
}
+ queryEditor={qe}
queryState={this.props.latestQuery?.state}
runQuery={this.runQuery}
- selectedText={qe.selectedText}
stopQuery={this.stopQuery}
- sql={this.props.queryEditor.sql}
overlayCreateAsMenu={showMenu ? runMenuBtn : null}
/>
@@ -687,27 +621,17 @@ class SqlEditor extends React.PureComponent {
)}
-
-
- e.preventDefault()}>
- LIMIT:
-
- {this.convertToNumWithSpaces(
- this.props.queryEditor.queryLimit ||
- this.props.defaultQueryLimit,
- )}
-
-
-
-
-
+
{this.props.latestQuery && (
editor.id === props.queryEditorId,
- );
-
- return { sqlLab, ...props, queryEditor, queryEditors: sqlLab.queryEditors };
+function mapStateToProps({ sqlLab }, { queryEditor }) {
+ let { latestQueryId, dbId } = queryEditor;
+ if (sqlLab.unsavedQueryEditor.id === queryEditor.id) {
+ const { latestQueryId: unsavedQID, dbId: unsavedDBID } =
+ sqlLab.unsavedQueryEditor;
+ latestQueryId = unsavedQID || latestQueryId;
+ dbId = unsavedDBID || dbId;
+ }
+ const database = sqlLab.databases[dbId];
+ const latestQuery = sqlLab.queries[latestQueryId];
+
+ return {
+ queryEditors: sqlLab.queryEditors,
+ latestQuery,
+ database,
+ };
}
function mapDispatchToProps(dispatch) {
@@ -848,10 +779,10 @@ function mapDispatchToProps(dispatch) {
persistEditorHeight,
postStopQuery,
queryEditorSetAutorun,
- queryEditorSetQueryLimit,
queryEditorSetSql,
queryEditorSetAndSaveSql,
queryEditorSetTemplateParams,
+ runQueryFromSqlEditor,
runQuery,
saveQuery,
addSavedQueryToTabState,
diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
index fbe782534e37..97d80a4e88ec 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
@@ -32,7 +32,7 @@ import Collapse from 'src/components/Collapse';
import Icons from 'src/components/Icons';
import { TableSelectorMultiple } from 'src/components/TableSelector';
import { IconTooltip } from 'src/components/IconTooltip';
-import { QueryEditor } from 'src/SqlLab/types';
+import { QueryEditor, SchemaOption } from 'src/SqlLab/types';
import { DatabaseObject } from 'src/components/DatabaseSelector';
import { EmptyStateSmall } from 'src/components/EmptyState';
import {
@@ -55,7 +55,10 @@ interface actionsTypes {
setDatabases: (arg0: any) => {};
addDangerToast: (msg: string) => void;
queryEditorSetSchema: (queryEditor: QueryEditor, schema?: string) => void;
- queryEditorSetSchemaOptions: () => void;
+ queryEditorSetSchemaOptions: (
+ queryEditor: QueryEditor,
+ options: SchemaOption[],
+ ) => void;
queryEditorSetTableOptions: (
queryEditor: QueryEditor,
options: Array,
@@ -70,7 +73,6 @@ interface SqlEditorLeftBarProps {
actions: actionsTypes & TableElementProps['actions'];
database: DatabaseObject;
setEmptyState: Dispatch>;
- showDisabled: boolean;
}
const StyledScrollbarContainer = styled.div`
@@ -239,6 +241,15 @@ export default function SqlEditorLeftBar({
[actions],
);
+ const handleSchemasLoad = React.useCallback(
+ (options: Array) => {
+ if (queryEditorRef.current) {
+ actions.queryEditorSetSchemaOptions(queryEditorRef.current, options);
+ }
+ },
+ [actions],
+ );
+
return (
() => (
+
+));
+jest.mock('src/components/Select/Select', () => () => (
+
+));
+jest.mock('src/components/Select/AsyncSelect', () => () => (
+
+));
+
+const middlewares = [thunk];
+const mockStore = configureStore(middlewares);
+const setup = (queryEditor: QueryEditor, store?: Store) =>
+ render(, {
+ useRedux: true,
+ ...(store && { store }),
+ });
+
+describe('SqlEditorTabHeader', () => {
+ it('renders name', () => {
+ const { queryByText } = setup(defaultQueryEditor, mockStore(initialState));
+ expect(queryByText(defaultQueryEditor.name)).toBeTruthy();
+ expect(queryByText(extraQueryEditor1.name)).toBeFalsy();
+ expect(queryByText(extraQueryEditor2.name)).toBeFalsy();
+ });
+
+ it('renders name from unsaved changes', () => {
+ const expectedTitle = 'updated title';
+ const { queryByText } = setup(
+ defaultQueryEditor,
+ mockStore({
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ unsavedQueryEditor: {
+ id: defaultQueryEditor.id,
+ name: expectedTitle,
+ },
+ },
+ }),
+ );
+ expect(queryByText(expectedTitle)).toBeTruthy();
+ expect(queryByText(defaultQueryEditor.name)).toBeFalsy();
+ expect(queryByText(extraQueryEditor1.name)).toBeFalsy();
+ expect(queryByText(extraQueryEditor2.name)).toBeFalsy();
+ });
+
+ it('renders current name for unrelated unsaved changes', () => {
+ const unrelatedTitle = 'updated title';
+ const { queryByText } = setup(
+ defaultQueryEditor,
+ mockStore({
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ unsavedQueryEditor: {
+ id: `${defaultQueryEditor.id}-other`,
+ name: unrelatedTitle,
+ },
+ },
+ }),
+ );
+ expect(queryByText(defaultQueryEditor.name)).toBeTruthy();
+ expect(queryByText(unrelatedTitle)).toBeFalsy();
+ expect(queryByText(extraQueryEditor1.name)).toBeFalsy();
+ expect(queryByText(extraQueryEditor2.name)).toBeFalsy();
+ });
+
+ describe('with dropdown menus', () => {
+ let store = mockStore();
+ beforeEach(async () => {
+ store = mockStore(initialState);
+ const { getByTestId } = setup(defaultQueryEditor, store);
+ const dropdown = getByTestId('dropdown-trigger');
+
+ userEvent.click(dropdown);
+ });
+
+ it('should dispatch removeQueryEditor action', async () => {
+ await waitFor(() =>
+ expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
+ );
+
+ fireEvent.click(screen.getByTestId('close-tab-menu-option'));
+
+ const actions = store.getActions();
+ await waitFor(() =>
+ expect(actions[0]).toEqual({
+ type: REMOVE_QUERY_EDITOR,
+ queryEditor: defaultQueryEditor,
+ }),
+ );
+ });
+
+ it('should dispatch queryEditorSetTitle action', async () => {
+ await waitFor(() =>
+ expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
+ );
+ const expectedTitle = 'typed text';
+ const mockPrompt = jest
+ .spyOn(window, 'prompt')
+ .mockImplementation(() => expectedTitle);
+ fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
+
+ const actions = store.getActions();
+ await waitFor(() =>
+ expect(actions[0]).toEqual({
+ type: QUERY_EDITOR_SET_TITLE,
+ name: expectedTitle,
+ queryEditor: expect.objectContaining({
+ id: defaultQueryEditor.id,
+ }),
+ }),
+ );
+ mockPrompt.mockClear();
+ });
+
+ it('should dispatch toggleLeftBar action', async () => {
+ await waitFor(() =>
+ expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
+ );
+ fireEvent.click(screen.getByTestId('toggle-menu-option'));
+
+ const actions = store.getActions();
+ await waitFor(() =>
+ expect(actions[0]).toEqual({
+ type: QUERY_EDITOR_TOGGLE_LEFT_BAR,
+ hideLeftBar: !defaultQueryEditor.hideLeftBar,
+ queryEditor: expect.objectContaining({
+ id: defaultQueryEditor.id,
+ }),
+ }),
+ );
+ });
+
+ it('should dispatch removeAllOtherQueryEditors action', async () => {
+ await waitFor(() =>
+ expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
+ );
+ fireEvent.click(screen.getByTestId('close-all-other-menu-option'));
+
+ const actions = store.getActions();
+ await waitFor(() =>
+ expect(actions).toEqual([
+ {
+ type: REMOVE_QUERY_EDITOR,
+ queryEditor: initialState.sqlLab.queryEditors[1],
+ },
+ {
+ type: REMOVE_QUERY_EDITOR,
+ queryEditor: initialState.sqlLab.queryEditors[2],
+ },
+ ]),
+ );
+ });
+
+ it('should dispatch cloneQueryToNewTab action', async () => {
+ await waitFor(() =>
+ expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
+ );
+ fireEvent.click(screen.getByTestId('clone-tab-menu-option'));
+
+ const actions = store.getActions();
+ await waitFor(() =>
+ expect(actions[0]).toEqual({
+ type: ADD_QUERY_EDITOR,
+ queryEditor: expect.objectContaining({
+ name: `Copy of ${defaultQueryEditor.name}`,
+ sql: defaultQueryEditor.sql,
+ autorun: false,
+ }),
+ }),
+ );
+ });
+ });
+});
diff --git a/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx
new file mode 100644
index 000000000000..dce2f2700e6e
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/SqlEditorTabHeader/index.tsx
@@ -0,0 +1,147 @@
+/**
+ * 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, { useMemo } from 'react';
+import { bindActionCreators } from 'redux';
+import { useSelector, useDispatch, shallowEqual } from 'react-redux';
+import { Dropdown } from 'src/components/Dropdown';
+import { Menu } from 'src/components/Menu';
+import { styled, t, QueryState } from '@superset-ui/core';
+import {
+ removeQueryEditor,
+ removeAllOtherQueryEditors,
+ queryEditorSetTitle,
+ cloneQueryToNewTab,
+ toggleLeftBar,
+} from 'src/SqlLab/actions/sqlLab';
+import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
+import TabStatusIcon from '../TabStatusIcon';
+
+const TabTitleWrapper = styled.div`
+ display: flex;
+ align-items: center;
+`;
+const TabTitle = styled.span`
+ margin-right: ${({ theme }) => theme.gridUnit * 2}px;
+ text-transform: none;
+`;
+
+interface Props {
+ queryEditor: QueryEditor;
+}
+
+const SqlEditorTabHeader: React.FC = ({ queryEditor }) => {
+ const qe = useSelector(
+ ({ sqlLab: { unsavedQueryEditor } }) => ({
+ ...queryEditor,
+ ...(queryEditor.id === unsavedQueryEditor.id && unsavedQueryEditor),
+ }),
+ shallowEqual,
+ );
+ const queryStatus = useSelector(
+ ({ sqlLab }) => sqlLab.queries[qe.latestQueryId || '']?.state || '',
+ );
+ const dispatch = useDispatch();
+ const actions = useMemo(
+ () =>
+ bindActionCreators(
+ {
+ removeQueryEditor,
+ removeAllOtherQueryEditors,
+ queryEditorSetTitle,
+ cloneQueryToNewTab,
+ toggleLeftBar,
+ },
+ dispatch,
+ ),
+ [dispatch],
+ );
+
+ function renameTab() {
+ const newTitle = prompt(t('Enter a new title for the tab'));
+ if (newTitle) {
+ actions.queryEditorSetTitle(qe, newTitle);
+ }
+ }
+
+ return (
+
+
+ actions.removeQueryEditor(qe)}
+ data-test="close-tab-menu-option"
+ >
+
+
+
+ {t('Close tab')}
+
+
+
+
+
+ {t('Rename tab')}
+
+ actions.toggleLeftBar(qe)}
+ data-test="toggle-menu-option"
+ >
+
+
+
+ {qe.hideLeftBar ? t('Expand tool bar') : t('Hide tool bar')}
+
+ actions.removeAllOtherQueryEditors(qe)}
+ data-test="close-all-other-menu-option"
+ >
+
+
+
+ {t('Close all other tabs')}
+
+ actions.cloneQueryToNewTab(qe, false)}
+ data-test="clone-tab-menu-option"
+ >
+
+
+
+ {t('Duplicate tab')}
+
+
+ }
+ />
+ {qe.name} {' '}
+
+ );
+};
+
+export default SqlEditorTabHeader;
diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx
index 5de36bc99a45..bc290673be6c 100644
--- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx
+++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx
@@ -30,6 +30,7 @@ import { EditableTabs } from 'src/components/Tabs';
import TabbedSqlEditors from 'src/SqlLab/components/TabbedSqlEditors';
import SqlEditor from 'src/SqlLab/components/SqlEditor';
import { table, initialState } from 'src/SqlLab/fixtures';
+import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName';
fetchMock.get('glob:*/api/v1/database/*', {});
fetchMock.get('glob:*/savedqueryviewapi/api/get/*', {});
@@ -150,18 +151,6 @@ describe('TabbedSqlEditors', () => {
);
});
});
- it('should rename Tab', () => {
- global.prompt = () => 'new title';
- wrapper = getWrapper();
- sinon.stub(wrapper.instance().props.actions, 'queryEditorSetTitle');
-
- wrapper.instance().renameTab(queryEditors[0]);
- expect(
- wrapper.instance().props.actions.queryEditorSetTitle.getCall(0).args[1],
- ).toBe('new title');
-
- delete global.prompt;
- });
it('should removeQueryEditor', () => {
wrapper = getWrapper();
sinon.stub(wrapper.instance().props.actions, 'removeQueryEditor');
@@ -183,11 +172,11 @@ describe('TabbedSqlEditors', () => {
it('should properly increment query tab name', () => {
wrapper = getWrapper();
sinon.stub(wrapper.instance().props.actions, 'addQueryEditor');
-
+ const newTitle = newQueryTabName(wrapper.instance().props.queryEditors);
wrapper.instance().newQueryEditor();
expect(
wrapper.instance().props.actions.addQueryEditor.getCall(0).args[0].name,
- ).toContain('Untitled Query 2');
+ ).toContain(newTitle);
});
it('should duplicate query editor', () => {
wrapper = getWrapper();
diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx
index c97080c6e95a..c5b94f8b3544 100644
--- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx
+++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx
@@ -18,9 +18,7 @@
*/
import React from 'react';
import PropTypes from 'prop-types';
-import { Dropdown } from 'src/components/Dropdown';
import { EditableTabs } from 'src/components/Tabs';
-import { Menu } from 'src/components/Menu';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import URI from 'urijs';
@@ -33,7 +31,7 @@ import * as Actions from 'src/SqlLab/actions/sqlLab';
import { EmptyStateBig } from 'src/components/EmptyState';
import { newQueryTabName } from '../../utils/newQueryTabName';
import SqlEditor from '../SqlEditor';
-import TabStatusIcon from '../TabStatusIcon';
+import SqlEditorTabHeader from '../SqlEditorTabHeader';
const propTypes = {
actions: PropTypes.object.isRequired,
@@ -44,7 +42,6 @@ const propTypes = {
databases: PropTypes.object.isRequired,
queries: PropTypes.object.isRequired,
queryEditors: PropTypes.array,
- requestedQuery: PropTypes.object,
tabHistory: PropTypes.array.isRequired,
tables: PropTypes.array.isRequired,
offline: PropTypes.bool,
@@ -54,16 +51,10 @@ const propTypes = {
const defaultProps = {
queryEditors: [],
offline: false,
- requestedQuery: null,
saveQueryWarning: null,
scheduleQueryWarning: null,
};
-const TabTitleWrapper = styled.div`
- display: flex;
- align-items: center;
-`;
-
const StyledTab = styled.span`
line-height: 24px;
`;
@@ -86,10 +77,6 @@ class TabbedSqlEditors extends React.PureComponent {
dataPreviewQueries: [],
};
this.removeQueryEditor = this.removeQueryEditor.bind(this);
- this.renameTab = this.renameTab.bind(this);
- this.toggleLeftBar = this.toggleLeftBar.bind(this);
- this.removeAllOtherQueryEditors =
- this.removeAllOtherQueryEditors.bind(this);
this.duplicateQueryEditor = this.duplicateQueryEditor.bind(this);
this.handleSelect = this.handleSelect.bind(this);
this.handleEdit = this.handleEdit.bind(this);
@@ -236,14 +223,6 @@ class TabbedSqlEditors extends React.PureComponent {
window.history.replaceState({}, document.title, this.state.sqlLabUrl);
}
- renameTab(qe) {
- /* eslint no-alert: 0 */
- const newTitle = prompt(t('Enter a new title for the tab'));
- if (newTitle) {
- this.props.actions.queryEditorSetTitle(qe, newTitle);
- }
- }
-
activeQueryEditor() {
if (this.props.tabHistory.length === 0) {
return this.props.queryEditors[0];
@@ -304,106 +283,34 @@ class TabbedSqlEditors extends React.PureComponent {
this.props.actions.removeQueryEditor(qe);
}
- removeAllOtherQueryEditors(cqe) {
- this.props.queryEditors.forEach(
- qe => qe !== cqe && this.removeQueryEditor(qe),
- );
- }
-
duplicateQueryEditor(qe) {
this.props.actions.cloneQueryToNewTab(qe, false);
}
- toggleLeftBar(qe) {
- this.props.actions.toggleLeftBar(qe);
- }
-
render() {
const noQueryEditors = this.props.queryEditors?.length === 0;
- const editors = this.props.queryEditors.map(qe => {
- let latestQuery;
- if (qe.latestQueryId) {
- latestQuery = this.props.queries[qe.latestQueryId];
- }
- let database;
- if (qe.dbId) {
- database = this.props.databases[qe.dbId];
- }
- const state = latestQuery ? latestQuery.state : '';
-
- const menu = (
-
- );
- const tabHeader = (
-
-
- {qe.name} {' '}
-
- );
- return (
-
- xt.queryEditorId === qe.id)}
- queryEditorId={qe.id}
- editorQueries={this.state.queriesArray}
- dataPreviewQueries={this.state.dataPreviewQueries}
- latestQuery={latestQuery}
- database={database}
- actions={this.props.actions}
- hideLeftBar={qe.hideLeftBar}
- defaultQueryLimit={this.props.defaultQueryLimit}
- maxRow={this.props.maxRow}
- displayLimit={this.props.displayLimit}
- saveQueryWarning={this.props.saveQueryWarning}
- scheduleQueryWarning={this.props.scheduleQueryWarning}
- />
-
- );
- });
+ const editors = this.props.queryEditors?.map(qe => (
+ }
+ // for tests - key prop isn't handled by enzyme well bcs it's a react keyword
+ data-key={qe.id}
+ >
+ xt.queryEditorId === qe.id)}
+ queryEditor={qe}
+ editorQueries={this.state.queriesArray}
+ dataPreviewQueries={this.state.dataPreviewQueries}
+ actions={this.props.actions}
+ hideLeftBar={qe.hideLeftBar}
+ defaultQueryLimit={this.props.defaultQueryLimit}
+ maxRow={this.props.maxRow}
+ displayLimit={this.props.displayLimit}
+ saveQueryWarning={this.props.saveQueryWarning}
+ scheduleQueryWarning={this.props.scheduleQueryWarning}
+ />
+
+ ));
const emptyTab = (
@@ -472,7 +379,7 @@ class TabbedSqlEditors extends React.PureComponent {
TabbedSqlEditors.propTypes = propTypes;
TabbedSqlEditors.defaultProps = defaultProps;
-function mapStateToProps({ sqlLab, common, requestedQuery }) {
+function mapStateToProps({ sqlLab, common }) {
return {
databases: sqlLab.databases,
queryEditors: sqlLab.queryEditors,
@@ -486,7 +393,6 @@ function mapStateToProps({ sqlLab, common, requestedQuery }) {
maxRow: common.conf.SQL_MAX_ROW,
saveQueryWarning: common.conf.SQLLAB_SAVE_WARNING_MESSAGE,
scheduleQueryWarning: common.conf.SQLLAB_SCHEDULE_WARNING_MESSAGE,
- requestedQuery,
};
}
function mapDispatchToProps(dispatch) {
diff --git a/superset-frontend/src/SqlLab/components/TemplateParamsEditor/TemplateParamsEditor.test.tsx b/superset-frontend/src/SqlLab/components/TemplateParamsEditor/TemplateParamsEditor.test.tsx
index bc04030d28c8..a82b1e5859ca 100644
--- a/superset-frontend/src/SqlLab/components/TemplateParamsEditor/TemplateParamsEditor.test.tsx
+++ b/superset-frontend/src/SqlLab/components/TemplateParamsEditor/TemplateParamsEditor.test.tsx
@@ -17,38 +17,100 @@
* under the License.
*/
-import React, { ReactNode } from 'react';
+import React from 'react';
+import configureStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+import { Store } from 'redux';
import {
render,
fireEvent,
getByText,
waitFor,
} from 'spec/helpers/testing-library';
-import { ThemeProvider, supersetTheme } from '@superset-ui/core';
+import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
-import TemplateParamsEditor from 'src/SqlLab/components/TemplateParamsEditor';
+import TemplateParamsEditor, {
+ Props,
+} from 'src/SqlLab/components/TemplateParamsEditor';
-const ThemeWrapper = ({ children }: { children: ReactNode }) => (
- {children}
-);
+jest.mock('src/components/Select', () => () => (
+
+));
+jest.mock('src/components/Select/Select', () => () => (
+
+));
+jest.mock('src/components/Select/AsyncSelect', () => () => (
+
+));
+jest.mock('src/components/AsyncAceEditor', () => ({
+ ConfigEditor: ({ value }: { value: string }) => (
+ {value}
+ ),
+}));
+
+const middlewares = [thunk];
+const mockStore = configureStore(middlewares);
+const setup = (otherProps: Partial = {}, store?: Store) =>
+ render(
+ {}}
+ queryEditor={defaultQueryEditor}
+ {...otherProps}
+ />,
+ {
+ useRedux: true,
+ store: mockStore(initialState),
+ ...(store && { store }),
+ },
+ );
describe('TemplateParamsEditor', () => {
it('should render with a title', () => {
- const { container } = render(
- {}} />,
- { wrapper: ThemeWrapper },
- );
+ const { container } = setup();
expect(container.querySelector('div[role="button"]')).toBeInTheDocument();
});
it('should open a modal with the ace editor', async () => {
- const { container, baseElement } = render(
- {}} />,
- { wrapper: ThemeWrapper },
+ const { container, getByTestId } = setup();
+ fireEvent.click(getByText(container, 'Parameters'));
+ await waitFor(() => {
+ expect(getByTestId('mock-async-ace-editor')).toBeInTheDocument();
+ });
+ });
+
+ it('renders templateParams', async () => {
+ const { container, getByTestId } = setup();
+ fireEvent.click(getByText(container, 'Parameters'));
+ await waitFor(() => {
+ expect(getByTestId('mock-async-ace-editor')).toBeInTheDocument();
+ });
+ expect(getByTestId('mock-async-ace-editor')).toHaveTextContent(
+ defaultQueryEditor.templateParams,
+ );
+ });
+
+ it('renders code from unsaved changes', async () => {
+ const expectedCode = 'custom code value';
+ const { container, getByTestId } = setup(
+ {},
+ mockStore({
+ ...initialState,
+ sqlLab: {
+ ...initialState.sqlLab,
+ unsavedQueryEditor: {
+ id: defaultQueryEditor.id,
+ templateParams: expectedCode,
+ },
+ },
+ }),
);
fireEvent.click(getByText(container, 'Parameters'));
await waitFor(() => {
- expect(baseElement.querySelector('#ace-editor')).toBeInTheDocument();
+ expect(getByTestId('mock-async-ace-editor')).toBeInTheDocument();
});
+ expect(getByTestId('mock-async-ace-editor')).toHaveTextContent(
+ expectedCode,
+ );
});
});
diff --git a/superset-frontend/src/SqlLab/components/TemplateParamsEditor/index.tsx b/superset-frontend/src/SqlLab/components/TemplateParamsEditor/index.tsx
index 62d0a7209de1..4eea10da05fe 100644
--- a/superset-frontend/src/SqlLab/components/TemplateParamsEditor/index.tsx
+++ b/superset-frontend/src/SqlLab/components/TemplateParamsEditor/index.tsx
@@ -26,6 +26,9 @@ import ModalTrigger from 'src/components/ModalTrigger';
import { ConfigEditor } from 'src/components/AsyncAceEditor';
import { FAST_DEBOUNCE } from 'src/constants';
import { Tooltip } from 'src/components/Tooltip';
+import { useSelector } from 'react-redux';
+import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
+import { getUpToDateQuery } from 'src/SqlLab/actions/sqlLab';
const StyledConfigEditor = styled(ConfigEditor)`
&.ace_editor {
@@ -33,17 +36,24 @@ const StyledConfigEditor = styled(ConfigEditor)`
}
`;
+export type Props = {
+ queryEditor: QueryEditor;
+ language: 'yaml' | 'json';
+ onChange: () => void;
+};
+
function TemplateParamsEditor({
- code = '{}',
+ queryEditor,
language,
onChange = () => {},
-}: {
- code: string;
- language: 'yaml' | 'json';
- onChange: () => void;
-}) {
+}: Props) {
const [parsedJSON, setParsedJSON] = useState({});
const [isValid, setIsValid] = useState(true);
+ const code = useSelector(
+ rootState =>
+ (getUpToDateQuery(rootState, queryEditor) as unknown as QueryEditor)
+ .templateParams || '{}',
+ );
useEffect(() => {
try {
diff --git a/superset-frontend/src/SqlLab/fixtures.ts b/superset-frontend/src/SqlLab/fixtures.ts
index ea0fbd1bb8d1..72c1c0d50f34 100644
--- a/superset-frontend/src/SqlLab/fixtures.ts
+++ b/superset-frontend/src/SqlLab/fixtures.ts
@@ -178,18 +178,38 @@ export const table = {
export const defaultQueryEditor = {
id: 'dfsadfs',
autorun: false,
- dbId: null,
+ dbId: undefined,
latestQueryId: null,
- selectedText: null,
+ selectedText: undefined,
sql: 'SELECT *\nFROM\nWHERE',
name: 'Untitled Query 1',
+ schema: 'main',
+ remoteId: null,
+ tableOptions: [],
+ functionNames: [],
+ hideLeftBar: false,
schemaOptions: [
{
value: 'main',
label: 'main',
- name: 'main',
+ title: 'main',
},
],
+ templateParams: '{}',
+};
+
+export const extraQueryEditor1 = {
+ ...defaultQueryEditor,
+ id: 'diekd23',
+ sql: 'SELECT *\nFROM\nWHERE\nLIMIT',
+ name: 'Untitled Query 2',
+};
+
+export const extraQueryEditor2 = {
+ ...defaultQueryEditor,
+ id: 'owkdi998',
+ sql: 'SELECT *\nFROM\nWHERE\nGROUP BY',
+ name: 'Untitled Query 3',
};
export const queries = [
@@ -640,13 +660,14 @@ export const initialState = {
alerts: [],
queries: {},
databases: {},
- queryEditors: [defaultQueryEditor],
+ queryEditors: [defaultQueryEditor, extraQueryEditor1, extraQueryEditor2],
tabHistory: [defaultQueryEditor.id],
tables: [],
workspaceQueries: [],
queriesLastUpdate: 0,
activeSouthPaneTab: 'Results',
user: { user },
+ unsavedQueryEditor: {},
},
messageToasts: [],
common: {
diff --git a/superset-frontend/src/SqlLab/reducers/getInitialState.js b/superset-frontend/src/SqlLab/reducers/getInitialState.js
index 21c367844d7f..2d00d3e0d68b 100644
--- a/superset-frontend/src/SqlLab/reducers/getInitialState.js
+++ b/superset-frontend/src/SqlLab/reducers/getInitialState.js
@@ -37,7 +37,7 @@ export default function getInitialState({
* To allow for a transparent migration, the initial state is a combination
* of the backend state (if any) with the browser state (if any).
*/
- const queryEditors = [];
+ let queryEditors = {};
const defaultQueryEditor = {
id: null,
loaded: true,
@@ -55,13 +55,9 @@ export default function getInitialState({
errors: [],
completed: false,
},
- queryCostEstimate: {
- cost: null,
- completed: false,
- error: null,
- },
hideLeftBar: false,
};
+ let unsavedQueryEditor = {};
/**
* Load state from the backend. This will be empty if the feature flag
@@ -102,7 +98,10 @@ export default function getInitialState({
name: label,
};
}
- queryEditors.push(queryEditor);
+ queryEditors = {
+ ...queryEditors,
+ [queryEditor.id]: queryEditor,
+ };
});
const tabHistory = activeTab ? [activeTab.id.toString()] : [];
@@ -160,15 +159,22 @@ export default function getInitialState({
// migration was successful
localStorage.removeItem('redux');
} else {
+ unsavedQueryEditor = sqlLab.unsavedQueryEditor || {};
// add query editors and tables to state with a special flag so they can
// be migrated if the `SQLLAB_BACKEND_PERSISTENCE` feature flag is on
- sqlLab.queryEditors.forEach(qe =>
- queryEditors.push({
- ...qe,
- inLocalStorage: true,
- loaded: true,
- }),
- );
+ sqlLab.queryEditors.forEach(qe => {
+ queryEditors = {
+ ...queryEditors,
+ [qe.id]: {
+ ...queryEditors[qe.id],
+ ...qe,
+ name: qe.title || qe.name,
+ ...(unsavedQueryEditor.id === qe.id && unsavedQueryEditor),
+ inLocalStorage: true,
+ loaded: true,
+ },
+ };
+ });
sqlLab.tables.forEach(table =>
tables.push({ ...table, inLocalStorage: true }),
);
@@ -186,11 +192,13 @@ export default function getInitialState({
databases,
offline: false,
queries,
- queryEditors,
+ queryEditors: Object.values(queryEditors),
tabHistory,
tables,
queriesLastUpdate: Date.now(),
user,
+ unsavedQueryEditor,
+ queryCostEstimates: {},
},
requestedQuery,
messageToasts: getToastsFromPyFlashMessages(
diff --git a/superset-frontend/src/SqlLab/reducers/sqlLab.js b/superset-frontend/src/SqlLab/reducers/sqlLab.js
index f87c8ce9c104..a12db71a37dd 100644
--- a/superset-frontend/src/SqlLab/reducers/sqlLab.js
+++ b/superset-frontend/src/SqlLab/reducers/sqlLab.js
@@ -31,12 +31,28 @@ import {
extendArr,
} from '../../reduxUtils';
+function alterUnsavedQueryEditorState(state, updatedState, id) {
+ return {
+ ...(state.unsavedQueryEditor.id === id && state.unsavedQueryEditor),
+ ...(id ? { id, ...updatedState } : state.unsavedQueryEditor),
+ };
+}
+
export default function sqlLabReducer(state = {}, action) {
const actionHandlers = {
[actions.ADD_QUERY_EDITOR]() {
- const tabHistory = state.tabHistory.slice();
- tabHistory.push(action.queryEditor.id);
- const newState = { ...state, tabHistory };
+ const mergeUnsavedState = alterInArr(
+ state,
+ 'queryEditors',
+ state.unsavedQueryEditor,
+ {
+ ...state.unsavedQueryEditor,
+ },
+ );
+ const newState = {
+ ...mergeUnsavedState,
+ tabHistory: [...state.tabHistory, action.queryEditor.id],
+ };
return addToArr(newState, 'queryEditors', action.queryEditor);
},
[actions.QUERY_EDITOR_SAVED]() {
@@ -66,9 +82,14 @@ export default function sqlLabReducer(state = {}, action) {
);
},
[actions.CLONE_QUERY_TO_NEW_TAB]() {
- const progenitor = state.queryEditors.find(
+ const queryEditor = state.queryEditors.find(
qe => qe.id === state.tabHistory[state.tabHistory.length - 1],
);
+ const progenitor = {
+ ...queryEditor,
+ ...(state.unsavedQueryEditor.id === queryEditor.id &&
+ state.unsavedQueryEditor),
+ };
const qe = {
remoteId: progenitor.remoteId,
name: t('Copy of %s', progenitor.name),
@@ -79,7 +100,14 @@ export default function sqlLabReducer(state = {}, action) {
queryLimit: action.query.queryLimit,
maxRow: action.query.maxRow,
};
- return sqlLabReducer(state, actions.addQueryEditor(qe));
+ const stateWithoutUnsavedState = {
+ ...state,
+ unsavedQueryEditor: {},
+ };
+ return sqlLabReducer(
+ stateWithoutUnsavedState,
+ actions.addQueryEditor(qe),
+ );
},
[actions.REMOVE_QUERY_EDITOR]() {
let newState = removeFromArr(state, 'queryEditors', action.queryEditor);
@@ -183,16 +211,20 @@ export default function sqlLabReducer(state = {}, action) {
};
},
[actions.START_QUERY_VALIDATION]() {
- let newState = { ...state };
- const sqlEditor = { id: action.query.sqlEditorId };
- newState = alterInArr(newState, 'queryEditors', sqlEditor, {
- validationResult: {
- id: action.query.id,
- errors: [],
- completed: false,
- },
- });
- return newState;
+ return {
+ ...state,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ validationResult: {
+ id: action.query.id,
+ errors: [],
+ completed: false,
+ },
+ },
+ action.query.sqlEditorId,
+ ),
+ };
},
[actions.QUERY_VALIDATION_RETURNED]() {
// If the server is very slow about answering us, we might get validation
@@ -202,21 +234,29 @@ export default function sqlLabReducer(state = {}, action) {
// We don't care about any but the most recent because validations are
// only valid for the SQL text they correspond to -- once the SQL has
// changed, the old validation doesn't tell us anything useful anymore.
- const qe = getFromArr(state.queryEditors, action.query.sqlEditorId);
+ const qe = {
+ ...getFromArr(state.queryEditors, action.query.sqlEditorId),
+ ...(state.unsavedQueryEditor.id === action.query.sqlEditorId &&
+ state.unsavedQueryEditor),
+ };
if (qe.validationResult.id !== action.query.id) {
return state;
}
// Otherwise, persist the results on the queryEditor state
- let newState = { ...state };
- const sqlEditor = { id: action.query.sqlEditorId };
- newState = alterInArr(newState, 'queryEditors', sqlEditor, {
- validationResult: {
- id: action.query.id,
- errors: action.results,
- completed: true,
- },
- });
- return newState;
+ return {
+ ...state,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ validationResult: {
+ id: action.query.id,
+ errors: action.results,
+ completed: true,
+ },
+ },
+ action.query.sqlEditorId,
+ ),
+ };
},
[actions.QUERY_VALIDATION_FAILED]() {
// If the server is very slow about answering us, we might get validation
@@ -250,45 +290,52 @@ export default function sqlLabReducer(state = {}, action) {
return newState;
},
[actions.COST_ESTIMATE_STARTED]() {
- let newState = { ...state };
- const sqlEditor = { id: action.query.sqlEditorId };
- newState = alterInArr(newState, 'queryEditors', sqlEditor, {
- queryCostEstimate: {
- completed: false,
- cost: null,
- error: null,
+ return {
+ ...state,
+ queryCostEstimates: {
+ ...state.queryCostEstimates,
+ [action.query.sqlEditorId]: {
+ completed: false,
+ cost: null,
+ error: null,
+ },
},
- });
- return newState;
+ };
},
[actions.COST_ESTIMATE_RETURNED]() {
- let newState = { ...state };
- const sqlEditor = { id: action.query.sqlEditorId };
- newState = alterInArr(newState, 'queryEditors', sqlEditor, {
- queryCostEstimate: {
- completed: true,
- cost: action.json,
- error: null,
+ return {
+ ...state,
+ queryCostEstimates: {
+ ...state.queryCostEstimates,
+ [action.query.sqlEditorId]: {
+ completed: true,
+ cost: action.json,
+ error: null,
+ },
},
- });
- return newState;
+ };
},
[actions.COST_ESTIMATE_FAILED]() {
- let newState = { ...state };
- const sqlEditor = { id: action.query.sqlEditorId };
- newState = alterInArr(newState, 'queryEditors', sqlEditor, {
- queryCostEstimate: {
- completed: false,
- cost: null,
- error: action.error,
+ return {
+ ...state,
+ queryCostEstimates: {
+ ...state.queryCostEstimates,
+ [action.query.sqlEditorId]: {
+ completed: false,
+ cost: null,
+ error: action.error,
+ },
},
- });
- return newState;
+ };
},
[actions.START_QUERY]() {
let newState = { ...state };
if (action.query.sqlEditorId) {
- const qe = getFromArr(state.queryEditors, action.query.sqlEditorId);
+ const qe = {
+ ...getFromArr(state.queryEditors, action.query.sqlEditorId),
+ ...(action.query.sqlEditorId === state.unsavedQueryEditor.id &&
+ state.unsavedQueryEditor),
+ };
if (qe.latestQueryId && state.queries[qe.latestQueryId]) {
const newResults = {
...state.queries[qe.latestQueryId].results,
@@ -303,10 +350,17 @@ export default function sqlLabReducer(state = {}, action) {
newState.activeSouthPaneTab = action.query.id;
}
newState = addToObject(newState, 'queries', action.query);
- const sqlEditor = { id: action.query.sqlEditorId };
- return alterInArr(newState, 'queryEditors', sqlEditor, {
- latestQueryId: action.query.id,
- });
+
+ return {
+ ...newState,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ latestQueryId: action.query.id,
+ },
+ action.query.sqlEditorId,
+ ),
+ };
},
[actions.STOP_QUERY]() {
return alterInObject(state, 'queries', action.query, {
@@ -371,14 +425,41 @@ export default function sqlLabReducer(state = {}, action) {
qeIds.indexOf(action.queryEditor?.id) > -1 &&
state.tabHistory[state.tabHistory.length - 1] !== action.queryEditor.id
) {
- const tabHistory = state.tabHistory.slice();
- tabHistory.push(action.queryEditor.id);
- return { ...state, tabHistory };
+ const mergeUnsavedState = alterInArr(
+ state,
+ 'queryEditors',
+ state.unsavedQueryEditor,
+ {
+ ...state.unsavedQueryEditor,
+ },
+ );
+ return {
+ ...(action.queryEditor.id === state.unsavedQueryEditor.id
+ ? alterInObject(
+ mergeUnsavedState,
+ 'queryEditors',
+ action.queryEditor,
+ {
+ ...action.queryEditor,
+ ...state.unsavedQueryEditor,
+ },
+ )
+ : mergeUnsavedState),
+ tabHistory: [...state.tabHistory, action.queryEditor.id],
+ };
}
return state;
},
[actions.LOAD_QUERY_EDITOR]() {
- return alterInArr(state, 'queryEditors', action.queryEditor, {
+ const mergeUnsavedState = alterInArr(
+ state,
+ 'queryEditors',
+ state.unsavedQueryEditor,
+ {
+ ...state.unsavedQueryEditor,
+ },
+ );
+ return alterInArr(mergeUnsavedState, 'queryEditors', action.queryEditor, {
...action.queryEditor,
});
},
@@ -441,70 +522,161 @@ export default function sqlLabReducer(state = {}, action) {
return { ...state, queries };
},
[actions.QUERY_EDITOR_SETDB]() {
- return alterInArr(state, 'queryEditors', action.queryEditor, {
- dbId: action.dbId,
- });
+ return {
+ ...state,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ dbId: action.dbId,
+ },
+ action.queryEditor.id,
+ ),
+ };
},
[actions.QUERY_EDITOR_SET_FUNCTION_NAMES]() {
- return alterInArr(state, 'queryEditors', action.queryEditor, {
- functionNames: action.functionNames,
- });
+ return {
+ ...state,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ functionNames: action.functionNames,
+ },
+ action.queryEditor.id,
+ ),
+ };
},
[actions.QUERY_EDITOR_SET_SCHEMA]() {
- return alterInArr(state, 'queryEditors', action.queryEditor, {
- schema: action.schema,
- });
+ return {
+ ...state,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ schema: action.schema,
+ },
+ action.queryEditor.id,
+ ),
+ };
},
[actions.QUERY_EDITOR_SET_SCHEMA_OPTIONS]() {
- return alterInArr(state, 'queryEditors', action.queryEditor, {
- schemaOptions: action.options,
- });
+ return {
+ ...state,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ schemaOptions: action.options,
+ },
+ action.queryEditor.id,
+ ),
+ };
},
[actions.QUERY_EDITOR_SET_TABLE_OPTIONS]() {
- return alterInArr(state, 'queryEditors', action.queryEditor, {
- tableOptions: action.options,
- });
+ return {
+ ...state,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ tableOptions: action.options,
+ },
+ action.queryEditor.id,
+ ),
+ };
},
[actions.QUERY_EDITOR_SET_TITLE]() {
- return alterInArr(state, 'queryEditors', action.queryEditor, {
- name: action.name,
- });
+ return {
+ ...state,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ name: action.name,
+ },
+ action.queryEditor.id,
+ ),
+ };
},
[actions.QUERY_EDITOR_SET_SQL]() {
- return alterInArr(state, 'queryEditors', action.queryEditor, {
- sql: action.sql,
- });
+ return {
+ ...state,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ sql: action.sql,
+ },
+ action.queryEditor.id,
+ ),
+ };
},
[actions.QUERY_EDITOR_SET_QUERY_LIMIT]() {
- return alterInArr(state, 'queryEditors', action.queryEditor, {
- queryLimit: action.queryLimit,
- });
+ return {
+ ...state,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ queryLimit: action.queryLimit,
+ },
+ action.queryEditor.id,
+ ),
+ };
},
[actions.QUERY_EDITOR_SET_TEMPLATE_PARAMS]() {
- return alterInArr(state, 'queryEditors', action.queryEditor, {
- templateParams: action.templateParams,
- });
+ return {
+ ...state,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ templateParams: action.templateParams,
+ },
+ action.queryEditor.id,
+ ),
+ };
},
[actions.QUERY_EDITOR_SET_SELECTED_TEXT]() {
- return alterInArr(state, 'queryEditors', action.queryEditor, {
- selectedText: action.sql,
- });
+ return {
+ ...state,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ selectedText: action.sql,
+ },
+ action.queryEditor.id,
+ ),
+ };
},
[actions.QUERY_EDITOR_SET_AUTORUN]() {
- return alterInArr(state, 'queryEditors', action.queryEditor, {
- autorun: action.autorun,
- });
+ return {
+ ...state,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ autorun: action.autorun,
+ },
+ action.queryEditor.id,
+ ),
+ };
},
[actions.QUERY_EDITOR_PERSIST_HEIGHT]() {
- return alterInArr(state, 'queryEditors', action.queryEditor, {
- northPercent: action.northPercent,
- southPercent: action.southPercent,
- });
+ return {
+ ...state,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ northPercent: action.northPercent,
+ southPercent: action.southPercent,
+ },
+ action.queryEditor.id,
+ ),
+ };
},
[actions.QUERY_EDITOR_TOGGLE_LEFT_BAR]() {
- return alterInArr(state, 'queryEditors', action.queryEditor, {
- hideLeftBar: action.hideLeftBar,
- });
+ return {
+ ...state,
+ unsavedQueryEditor: alterUnsavedQueryEditorState(
+ state,
+ {
+ hideLeftBar: action.hideLeftBar,
+ },
+ action.queryEditor.id,
+ ),
+ };
},
[actions.SET_DATABASES]() {
const databases = {};
diff --git a/superset-frontend/src/SqlLab/reducers/sqlLab.test.js b/superset-frontend/src/SqlLab/reducers/sqlLab.test.js
index 4c92e1324740..82483712f884 100644
--- a/superset-frontend/src/SqlLab/reducers/sqlLab.test.js
+++ b/superset-frontend/src/SqlLab/reducers/sqlLab.test.js
@@ -39,23 +39,55 @@ describe('sqlLabReducer', () => {
qe = newState.queryEditors.find(e => e.id === 'abcd');
});
it('should add a query editor', () => {
- expect(newState.queryEditors).toHaveLength(2);
+ expect(newState.queryEditors).toHaveLength(
+ initialState.queryEditors.length + 1,
+ );
+ });
+ it('should merge the current unsaved changes when adding a query editor', () => {
+ const expectedTitle = 'new updated title';
+ const updateAction = {
+ type: actions.QUERY_EDITOR_SET_TITLE,
+ queryEditor: initialState.queryEditors[0],
+ name: expectedTitle,
+ };
+ newState = sqlLabReducer(newState, updateAction);
+ const addAction = {
+ type: actions.ADD_QUERY_EDITOR,
+ queryEditor: { ...initialState.queryEditors[0], id: 'efgh' },
+ };
+ newState = sqlLabReducer(newState, addAction);
+
+ expect(newState.queryEditors[0].name).toEqual(expectedTitle);
+ expect(
+ newState.queryEditors[newState.queryEditors.length - 1].id,
+ ).toEqual('efgh');
});
it('should remove a query editor', () => {
- expect(newState.queryEditors).toHaveLength(2);
+ expect(newState.queryEditors).toHaveLength(
+ initialState.queryEditors.length + 1,
+ );
const action = {
type: actions.REMOVE_QUERY_EDITOR,
queryEditor: qe,
};
newState = sqlLabReducer(newState, action);
- expect(newState.queryEditors).toHaveLength(1);
+ expect(newState.queryEditors).toHaveLength(
+ initialState.queryEditors.length,
+ );
});
it('should set q query editor active', () => {
+ const expectedTitle = 'new updated title';
const addQueryEditorAction = {
type: actions.ADD_QUERY_EDITOR,
queryEditor: { ...initialState.queryEditors[0], id: 'abcd' },
};
newState = sqlLabReducer(newState, addQueryEditorAction);
+ const updateAction = {
+ type: actions.QUERY_EDITOR_SET_TITLE,
+ queryEditor: initialState.queryEditors[1],
+ name: expectedTitle,
+ };
+ newState = sqlLabReducer(newState, updateAction);
const setActiveQueryEditorAction = {
type: actions.SET_ACTIVE_QUERY_EDITOR,
queryEditor: defaultQueryEditor,
@@ -64,6 +96,7 @@ describe('sqlLabReducer', () => {
expect(newState.tabHistory[newState.tabHistory.length - 1]).toBe(
defaultQueryEditor.id,
);
+ expect(newState.queryEditors[1].name).toEqual(expectedTitle);
});
it('should not fail while setting DB', () => {
const dbId = 9;
@@ -73,7 +106,8 @@ describe('sqlLabReducer', () => {
dbId,
};
newState = sqlLabReducer(newState, action);
- expect(newState.queryEditors[1].dbId).toBe(dbId);
+ expect(newState.unsavedQueryEditor.dbId).toBe(dbId);
+ expect(newState.unsavedQueryEditor.id).toBe(qe.id);
});
it('should not fail while setting schema', () => {
const schema = 'foo';
@@ -83,7 +117,8 @@ describe('sqlLabReducer', () => {
schema,
};
newState = sqlLabReducer(newState, action);
- expect(newState.queryEditors[1].schema).toBe(schema);
+ expect(newState.unsavedQueryEditor.schema).toBe(schema);
+ expect(newState.unsavedQueryEditor.id).toBe(qe.id);
});
it('should not fail while setting autorun', () => {
const action = {
@@ -91,19 +126,22 @@ describe('sqlLabReducer', () => {
queryEditor: qe,
};
newState = sqlLabReducer(newState, { ...action, autorun: false });
- expect(newState.queryEditors[1].autorun).toBe(false);
+ expect(newState.unsavedQueryEditor.autorun).toBe(false);
+ expect(newState.unsavedQueryEditor.id).toBe(qe.id);
newState = sqlLabReducer(newState, { ...action, autorun: true });
- expect(newState.queryEditors[1].autorun).toBe(true);
+ expect(newState.unsavedQueryEditor.autorun).toBe(true);
+ expect(newState.unsavedQueryEditor.id).toBe(qe.id);
});
it('should not fail while setting title', () => {
const title = 'Untitled Query 1';
const action = {
type: actions.QUERY_EDITOR_SET_TITLE,
queryEditor: qe,
- title,
+ name: title,
};
newState = sqlLabReducer(newState, action);
- expect(newState.queryEditors[0].name).toBe(title);
+ expect(newState.unsavedQueryEditor.name).toBe(title);
+ expect(newState.unsavedQueryEditor.id).toBe(qe.id);
});
it('should not fail while setting Sql', () => {
const sql = 'SELECT nothing from dev_null';
@@ -113,7 +151,8 @@ describe('sqlLabReducer', () => {
sql,
};
newState = sqlLabReducer(newState, action);
- expect(newState.queryEditors[1].sql).toBe(sql);
+ expect(newState.unsavedQueryEditor.sql).toBe(sql);
+ expect(newState.unsavedQueryEditor.id).toBe(qe.id);
});
it('should not fail while setting queryLimit', () => {
const queryLimit = 101;
@@ -123,7 +162,8 @@ describe('sqlLabReducer', () => {
queryLimit,
};
newState = sqlLabReducer(newState, action);
- expect(newState.queryEditors[1].queryLimit).toEqual(queryLimit);
+ expect(newState.unsavedQueryEditor.queryLimit).toBe(queryLimit);
+ expect(newState.unsavedQueryEditor.id).toBe(qe.id);
});
it('should set selectedText', () => {
const selectedText = 'TEST';
@@ -132,9 +172,10 @@ describe('sqlLabReducer', () => {
queryEditor: newState.queryEditors[0],
sql: selectedText,
};
- expect(newState.queryEditors[0].selectedText).toBeNull();
+ expect(newState.queryEditors[0].selectedText).toBeFalsy();
newState = sqlLabReducer(newState, action);
- expect(newState.queryEditors[0].selectedText).toBe(selectedText);
+ expect(newState.unsavedQueryEditor.selectedText).toBe(selectedText);
+ expect(newState.unsavedQueryEditor.id).toBe(newState.queryEditors[0].id);
});
});
describe('Tables', () => {
diff --git a/superset-frontend/src/SqlLab/types.ts b/superset-frontend/src/SqlLab/types.ts
index 8bb3e73d2887..18c6773ae6ed 100644
--- a/superset-frontend/src/SqlLab/types.ts
+++ b/superset-frontend/src/SqlLab/types.ts
@@ -32,16 +32,26 @@ export type QueryDictionary = {
};
export interface QueryEditor {
+ id: string;
dbId?: number;
name: string;
schema: string;
autorun: boolean;
sql: string;
remoteId: number | null;
+ tableOptions: any[];
+ schemaOptions?: SchemaOption[];
+ functionNames: string[];
validationResult?: {
completed: boolean;
errors: SupersetError[];
};
+ hideLeftBar?: boolean;
+ latestQueryId?: string | null;
+ templateParams?: string;
+ selectedText?: string;
+ queryLimit?: number;
+ description?: string;
}
export type toastState = {
@@ -59,13 +69,15 @@ export type SqlLabRootState = {
databases: Record;
dbConnect: boolean;
offline: boolean;
- queries: Query[];
+ queries: Record;
queryEditors: QueryEditor[];
tabHistory: string[]; // default is activeTab ? [activeTab.id.toString()] : []
tables: Record[];
queriesLastUpdate: number;
user: UserWithPermissionsAndRoles;
errorMessage: string | null;
+ unsavedQueryEditor: Partial;
+ queryCostEstimates?: Record;
};
localStorageUsageInKilobytes: number;
messageToasts: toastState[];
@@ -113,3 +125,15 @@ export interface DatasetOptionAutocomplete {
datasetId: number;
owners: [DatasetOwner];
}
+
+export interface SchemaOption {
+ value: string;
+ label: string;
+ title: string;
+}
+
+export interface QueryCostEstimate {
+ completed: string;
+ cost: Record[];
+ error: string;
+}
diff --git a/superset-frontend/src/SqlLab/utils/newQueryTabName.test.ts b/superset-frontend/src/SqlLab/utils/newQueryTabName.test.ts
index 6f2af5ebb737..33eec7378161 100644
--- a/superset-frontend/src/SqlLab/utils/newQueryTabName.test.ts
+++ b/superset-frontend/src/SqlLab/utils/newQueryTabName.test.ts
@@ -17,9 +17,11 @@
* under the License.
*/
+import { defaultQueryEditor } from 'src/SqlLab/fixtures';
import { newQueryTabName } from './newQueryTabName';
const emptyEditor = {
+ ...defaultQueryEditor,
title: '',
schema: '',
autorun: false,
diff --git a/superset-frontend/src/components/Dropdown/index.tsx b/superset-frontend/src/components/Dropdown/index.tsx
index e5d5f9f8526c..bd01aabb4d55 100644
--- a/superset-frontend/src/components/Dropdown/index.tsx
+++ b/superset-frontend/src/components/Dropdown/index.tsx
@@ -66,7 +66,7 @@ const MenuDotsWrapper = styled.div`
padding-left: ${({ theme }) => theme.gridUnit}px;
`;
-export interface DropdownProps {
+export interface DropdownProps extends DropDownProps {
overlay: React.ReactElement;
}
diff --git a/superset-frontend/src/components/TableSelector/index.tsx b/superset-frontend/src/components/TableSelector/index.tsx
index 4c07e256cc2e..a41c4aa64584 100644
--- a/superset-frontend/src/components/TableSelector/index.tsx
+++ b/superset-frontend/src/components/TableSelector/index.tsx
@@ -36,6 +36,7 @@ import RefreshLabel from 'src/components/RefreshLabel';
import CertifiedBadge from 'src/components/CertifiedBadge';
import WarningIconWithTooltip from 'src/components/WarningIconWithTooltip';
import { useToasts } from 'src/components/MessageToasts/withToasts';
+import { SchemaOption } from 'src/SqlLab/types';
const TableSelectorWrapper = styled.div`
${({ theme }) => `
@@ -89,7 +90,7 @@ interface TableSelectorProps {
isDatabaseSelectEnabled?: boolean;
onDbChange?: (db: DatabaseObject) => void;
onSchemaChange?: (schema?: string) => void;
- onSchemasLoad?: () => void;
+ onSchemasLoad?: (schemaOptions: SchemaOption[]) => void;
onTablesLoad?: (options: Array) => void;
readOnly?: boolean;
schema?: string;