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 6c446467102e..bac563436a32 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 ( + + {[...new Set(LIMIT_DROPDOWN)].map(limit => ( + setQueryLimit(limit)}> + {/* // eslint-disable-line no-use-before-define */} + {convertToNumWithSpaces(limit)}{' '} + + ))} + + ); +} + +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 ( - - {[...new Set(LIMIT_DROPDOWN)].map(limit => ( - this.setQueryLimit(limit)}> - {/* // eslint-disable-line no-use-before-define */} - - {this.convertToNumWithSpaces(limit)} - {' '} - - ))} - - ); - } - 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 = ( - - this.removeQueryEditor(qe)} - data-test="close-tab-menu-option" - > -
- -
- {t('Close tab')} -
- this.renameTab(qe)}> -
- -
- {t('Rename tab')} -
- this.toggleLeftBar(qe)}> -
- -
- {qe.hideLeftBar ? t('Expand tool bar') : t('Hide tool bar')} -
- this.removeAllOtherQueryEditors(qe)} - > -
- -
- {t('Close all other tabs')} -
- this.duplicateQueryEditor(qe)}> -
- -
- {t('Duplicate tab')} -
-
- ); - 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;