perf(sqllab): Rendering perf improvement using immutable state (#20877)

* perf(sqllab): Rendering perf improvement using immutable state

- keep queryEditors immutable during active state
- add unsavedQueryEditor to store all active changes
- refactor each component to subscribe the related unsaved editor state only

* revert ISaveableDatasource type cast

* missing trigger prop

* a default of an empty object and optional operator
This commit is contained in:
JUST.in DO IT 2022-08-23 08:17:19 -07:00 committed by GitHub
parent 4ca4a5c7cb
commit f77b910e2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1929 additions and 606 deletions

View File

@ -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(),
);

View File

@ -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

View File

@ -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)) {

View File

@ -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);
});
});
});

View File

@ -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', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));
jest.mock('src/components/Select/AsyncSelect', () => () => (
<div data-test="mock-deprecated-async-select" />
));
jest.mock('src/components/AsyncAceEditor', () => ({
FullSQLEditor: (props: AsyncAceEditorProps) => (
<div data-test="react-ace">{JSON.stringify(props)}</div>
),
}));
const setup = (queryEditor: QueryEditor, store?: Store) =>
render(
<AceEditorWrapper
queryEditor={queryEditor}
actions={{
queryEditorSetSelectedText,
queryEditorSetFunctionNames,
addTable,
}}
height="100px"
hotkeys={[]}
database={{}}
onChange={jest.fn()}
onBlur={jest.fn()}
autocomplete
/>,
{
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),
);
});
});

View File

@ -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<Props, State> {
}
}
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<ReduxProps, {}, OwnProps>(mapStateToProps)(
AceEditorWrapper,
);

View File

@ -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', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));
jest.mock('src/components/Select/AsyncSelect', () => () => (
<div data-test="mock-deprecated-async-select" />
));
const setup = (props: Partial<EstimateQueryCostButtonProps>, store?: Store) =>
render(
<EstimateQueryCostButton
queryEditor={defaultQueryEditor}
getEstimate={jest.fn()}
{...props}
/>,
{
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();
});
});

View File

@ -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<string, any>;
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<SqlLabRootState, string | undefined>(
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 (
<Alert
key="query-estimate-error"
type="error"
message={queryCostEstimate.error}
message={queryCostEstimate?.error}
/>
);
}
if (queryCostEstimate.completed) {
if (queryCostEstimate?.completed) {
return (
<TableView
columns={columns}

View File

@ -0,0 +1,137 @@
/**
* 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 userEvent from '@testing-library/user-event';
import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
import QueryLimitSelect, {
LIMIT_DROPDOWN,
QueryLimitSelectProps,
convertToNumWithSpaces,
} from 'src/SqlLab/components/QueryLimitSelect';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
jest.mock('src/components/Select', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));
jest.mock('src/components/Select/AsyncSelect', () => () => (
<div data-test="mock-deprecated-async-select" />
));
jest.mock('src/components/Icons/Icon', () => () => (
<div data-test="mock-icons-icon" />
));
const defaultQueryLimit = 100;
const setup = (props?: Partial<QueryLimitSelectProps>, store?: Store) =>
render(
<QueryLimitSelect
queryEditor={defaultQueryEditor}
maxRow={100000}
defaultQueryLimit={defaultQueryLimit}
{...props}
/>,
{
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,
},
]),
);
});
});

View File

@ -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 (
<Menu>
{[...new Set(LIMIT_DROPDOWN)].map(limit => (
<Menu.Item key={`${limit}`} onClick={() => setQueryLimit(limit)}>
{/* // eslint-disable-line no-use-before-define */}
<a role="button">{convertToNumWithSpaces(limit)}</a>{' '}
</Menu.Item>
))}
</Menu>
);
}
const QueryLimitSelect = ({
queryEditor,
maxRow,
defaultQueryLimit,
}: QueryLimitSelectProps) => {
const queryLimit = useSelector<SqlLabRootState, number>(
({ 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 (
<LimitSelectStyled>
<AntdDropdown
overlay={renderQueryLimit(maxRow, setQueryLimit)}
trigger={['click']}
>
<button type="button" onClick={e => e.preventDefault()}>
<span>LIMIT:</span>
<span className="limitDropdown">
{convertToNumWithSpaces(queryLimit)}
</span>
<Icons.TriangleDown iconColor={theme.colors.grayscale.base} />
</button>
</AntdDropdown>
</LimitSelectStyled>
);
};
export default QueryLimitSelect;

View File

@ -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(<RunQueryActionButton {...defaultProps} />, {
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
});
});
it('is a valid react element', () => {
expect(
React.isValidElement(<RunQueryActionButton {...defaultProps} />),
).toBe(true);
});
it('renders a single Button', () => {
expect(wrapper.find(Button)).toExist();
});
});

View File

@ -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', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));
jest.mock('src/components/Select/AsyncSelect', () => () => (
<div data-test="mock-deprecated-async-select" />
));
const defaultProps = {
queryEditor: defaultQueryEditor,
allowAsync: false,
dbId: 1,
queryState: 'ready',
runQuery: jest.fn(),
selectedText: null,
stopQuery: jest.fn(),
overlayCreateAsMenu: null,
};
const setup = (props?: Partial<Props>, store?: Store) =>
render(<RunQueryActionButton {...defaultProps} {...props} />, {
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),
);
});
});
});

View File

@ -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<QueryEditor, 'selectedText' | 'sql'>
>(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(
() =>

View File

@ -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(<SaveQuery {...mockedProps} />, { useRedux: true });
render(<SaveQuery {...mockedProps} />, {
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(<SaveQuery {...mockedProps} />, { useRedux: true });
render(<SaveQuery {...mockedProps} />, {
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(<SaveQuery {...mockedProps} />, { useRedux: true });
render(<SaveQuery {...mockedProps} />, {
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(<SaveQuery {...props} />, { useRedux: true });
render(<SaveQuery {...props} />, {
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(<SaveQuery {...splitSaveBtnProps} />, { useRedux: true });
render(<SaveQuery {...splitSaveBtnProps} />, {
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(<SaveQuery {...splitSaveBtnProps} />, { useRedux: true });
render(<SaveQuery {...splitSaveBtnProps} />, {
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(<SaveQuery {...splitSaveBtnProps} />, { useRedux: true });
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(initialState),
});
await waitFor(() => {
const caretBtn = screen.getByRole('button', { name: /caret-down/i });

View File

@ -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<SqlLabRootState, QueryEditor>(
({ sqlLab: { unsavedQueryEditor } }) => ({
...queryEditor,
...(queryEditor.id === unsavedQueryEditor.id && unsavedQueryEditor),
columns,
}),
shallowEqual,
);
const defaultLabel = query.name || query.description || t('Undefined');
const [description, setDescription] = useState<string>(
query.description || '',
);
@ -100,11 +114,12 @@ export default function SaveQuery({
</Menu>
);
const queryPayload = () => ({
...query,
name: label,
description,
});
const queryPayload = () =>
({
...query,
name: label,
description,
} as any as QueryPayload);
useEffect(() => {
if (!isSaved) setLabel(defaultLabel);

View File

@ -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();
});

View File

@ -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}
/>
</Menu.Item>
)}
@ -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 (
<Menu>
{[...new Set(LIMIT_DROPDOWN)].map(limit => (
<Menu.Item key={`${limit}`} onClick={() => this.setQueryLimit(limit)}>
{/* // eslint-disable-line no-use-before-define */}
<a role="button" styling="link">
{this.convertToNumWithSpaces(limit)}
</a>{' '}
</Menu.Item>
))}
</Menu>
);
}
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}
/>
</span>
@ -687,27 +621,17 @@ class SqlEditor extends React.PureComponent {
<span>
<EstimateQueryCostButton
getEstimate={this.getQueryCostEstimate}
queryCostEstimate={qe.queryCostEstimate}
selectedText={qe.selectedText}
queryEditor={qe}
tooltip={t('Estimate the cost before running a query')}
/>
</span>
)}
<span>
<LimitSelectStyled>
<AntdDropdown overlay={this.renderQueryLimit()} trigger="click">
<a onClick={e => e.preventDefault()}>
<span>LIMIT:</span>
<span className="limitDropdown">
{this.convertToNumWithSpaces(
this.props.queryEditor.queryLimit ||
this.props.defaultQueryLimit,
)}
</span>
<Icons.TriangleDown iconColor={theme.colors.grayscale.base} />
</a>
</AntdDropdown>
</LimitSelectStyled>
<QueryLimitSelect
queryEditor={this.props.queryEditor}
maxRow={this.props.maxRow}
defaultQueryLimit={this.props.defaultQueryLimit}
/>
</span>
{this.props.latestQuery && (
<Timer
@ -721,11 +645,8 @@ class SqlEditor extends React.PureComponent {
<div className="rightItems">
<span>
<SaveQuery
query={{
...qe,
columns: this.props.latestQuery?.results?.columns || [],
}}
defaultLabel={qe.name || qe.description}
queryEditor={qe}
columns={this.props.latestQuery?.results?.columns || []}
onSave={this.saveQuery}
onUpdate={this.props.actions.updateSavedQuery}
saveQueryWarning={this.props.saveQueryWarning}
@ -832,12 +753,22 @@ class SqlEditor extends React.PureComponent {
SqlEditor.defaultProps = defaultProps;
SqlEditor.propTypes = propTypes;
function mapStateToProps({ sqlLab }, props) {
const queryEditor = sqlLab.queryEditors.find(
editor => editor.id === props.queryEditorId,
);
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 { sqlLab, ...props, queryEditor, queryEditors: sqlLab.queryEditors };
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,

View File

@ -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<any>,
@ -70,7 +73,6 @@ interface SqlEditorLeftBarProps {
actions: actionsTypes & TableElementProps['actions'];
database: DatabaseObject;
setEmptyState: Dispatch<SetStateAction<boolean>>;
showDisabled: boolean;
}
const StyledScrollbarContainer = styled.div`
@ -239,6 +241,15 @@ export default function SqlEditorLeftBar({
[actions],
);
const handleSchemasLoad = React.useCallback(
(options: Array<any>) => {
if (queryEditorRef.current) {
actions.queryEditorSetSchemaOptions(queryEditorRef.current, options);
}
},
[actions],
);
return (
<div className="SqlEditorLeftBar">
<TableSelectorMultiple
@ -249,7 +260,7 @@ export default function SqlEditorLeftBar({
handleError={actions.addDangerToast}
onDbChange={onDbChange}
onSchemaChange={handleSchemaChange}
onSchemasLoad={actions.queryEditorSetSchemaOptions}
onSchemasLoad={handleSchemasLoad}
onTableSelectChange={onTablesChange}
onTablesLoad={handleTablesLoad}
schema={queryEditor.schema}

View File

@ -0,0 +1,220 @@
/**
* 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 {
fireEvent,
screen,
render,
waitFor,
} from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { QueryEditor } from 'src/SqlLab/types';
import {
initialState,
defaultQueryEditor,
extraQueryEditor1,
extraQueryEditor2,
} from 'src/SqlLab/fixtures';
import { Store } from 'redux';
import {
REMOVE_QUERY_EDITOR,
QUERY_EDITOR_SET_TITLE,
ADD_QUERY_EDITOR,
QUERY_EDITOR_TOGGLE_LEFT_BAR,
} from 'src/SqlLab/actions/sqlLab';
import SqlEditorTabHeader from 'src/SqlLab/components/SqlEditorTabHeader';
jest.mock('src/components/Select', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));
jest.mock('src/components/Select/AsyncSelect', () => () => (
<div data-test="mock-async-select" />
));
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const setup = (queryEditor: QueryEditor, store?: Store) =>
render(<SqlEditorTabHeader queryEditor={queryEditor} />, {
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,
}),
}),
);
});
});
});

View File

@ -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<Props> = ({ queryEditor }) => {
const qe = useSelector<SqlLabRootState, QueryEditor>(
({ sqlLab: { unsavedQueryEditor } }) => ({
...queryEditor,
...(queryEditor.id === unsavedQueryEditor.id && unsavedQueryEditor),
}),
shallowEqual,
);
const queryStatus = useSelector<SqlLabRootState, QueryState>(
({ 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 (
<TabTitleWrapper>
<Dropdown
trigger={['click']}
overlay={
<Menu style={{ width: 176 }}>
<Menu.Item
className="close-btn"
key="1"
onClick={() => actions.removeQueryEditor(qe)}
data-test="close-tab-menu-option"
>
<div className="icon-container">
<i className="fa fa-close" />
</div>
{t('Close tab')}
</Menu.Item>
<Menu.Item
key="2"
onClick={renameTab}
data-test="rename-tab-menu-option"
>
<div className="icon-container">
<i className="fa fa-i-cursor" />
</div>
{t('Rename tab')}
</Menu.Item>
<Menu.Item
key="3"
onClick={() => actions.toggleLeftBar(qe)}
data-test="toggle-menu-option"
>
<div className="icon-container">
<i className="fa fa-cogs" />
</div>
{qe.hideLeftBar ? t('Expand tool bar') : t('Hide tool bar')}
</Menu.Item>
<Menu.Item
key="4"
onClick={() => actions.removeAllOtherQueryEditors(qe)}
data-test="close-all-other-menu-option"
>
<div className="icon-container">
<i className="fa fa-times-circle-o" />
</div>
{t('Close all other tabs')}
</Menu.Item>
<Menu.Item
key="5"
onClick={() => actions.cloneQueryToNewTab(qe, false)}
data-test="clone-tab-menu-option"
>
<div className="icon-container">
<i className="fa fa-files-o" />
</div>
{t('Duplicate tab')}
</Menu.Item>
</Menu>
}
/>
<TabTitle>{qe.name}</TabTitle> <TabStatusIcon tabState={queryStatus} />{' '}
</TabTitleWrapper>
);
};
export default SqlEditorTabHeader;

View File

@ -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();

View File

@ -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 = (
<Menu style={{ width: 176 }}>
<Menu.Item
className="close-btn"
key="1"
onClick={() => this.removeQueryEditor(qe)}
data-test="close-tab-menu-option"
>
<div className="icon-container">
<i className="fa fa-close" />
</div>
{t('Close tab')}
</Menu.Item>
<Menu.Item key="2" onClick={() => this.renameTab(qe)}>
<div className="icon-container">
<i className="fa fa-i-cursor" />
</div>
{t('Rename tab')}
</Menu.Item>
<Menu.Item key="3" onClick={() => this.toggleLeftBar(qe)}>
<div className="icon-container">
<i className="fa fa-cogs" />
</div>
{qe.hideLeftBar ? t('Expand tool bar') : t('Hide tool bar')}
</Menu.Item>
<Menu.Item
key="4"
onClick={() => this.removeAllOtherQueryEditors(qe)}
>
<div className="icon-container">
<i className="fa fa-times-circle-o" />
</div>
{t('Close all other tabs')}
</Menu.Item>
<Menu.Item key="5" onClick={() => this.duplicateQueryEditor(qe)}>
<div className="icon-container">
<i className="fa fa-files-o" />
</div>
{t('Duplicate tab')}
</Menu.Item>
</Menu>
);
const tabHeader = (
<TabTitleWrapper>
<Dropdown overlay={menu} trigger={['click']} />
<TabTitle>{qe.name}</TabTitle> <TabStatusIcon tabState={state} />{' '}
</TabTitleWrapper>
);
return (
<EditableTabs.TabPane
key={qe.id}
tab={tabHeader}
// for tests - key prop isn't handled by enzyme well bcs it's a react keyword
data-key={qe.id}
>
<SqlEditor
tables={this.props.tables.filter(xt => 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}
/>
</EditableTabs.TabPane>
);
});
const editors = this.props.queryEditors?.map(qe => (
<EditableTabs.TabPane
key={qe.id}
tab={<SqlEditorTabHeader queryEditor={qe} />}
// for tests - key prop isn't handled by enzyme well bcs it's a react keyword
data-key={qe.id}
>
<SqlEditor
tables={this.props.tables.filter(xt => 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}
/>
</EditableTabs.TabPane>
));
const emptyTab = (
<StyledTab>
@ -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) {

View File

@ -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 }) => (
<ThemeProvider theme={supersetTheme}>{children}</ThemeProvider>
);
jest.mock('src/components/Select', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));
jest.mock('src/components/Select/AsyncSelect', () => () => (
<div data-test="mock-async-select" />
));
jest.mock('src/components/AsyncAceEditor', () => ({
ConfigEditor: ({ value }: { value: string }) => (
<div data-test="mock-async-ace-editor">{value}</div>
),
}));
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const setup = (otherProps: Partial<Props> = {}, store?: Store) =>
render(
<TemplateParamsEditor
language="json"
onChange={() => {}}
queryEditor={defaultQueryEditor}
{...otherProps}
/>,
{
useRedux: true,
store: mockStore(initialState),
...(store && { store }),
},
);
describe('TemplateParamsEditor', () => {
it('should render with a title', () => {
const { container } = render(
<TemplateParamsEditor code="FOO" language="json" onChange={() => {}} />,
{ 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(
<TemplateParamsEditor code="FOO" language="json" onChange={() => {}} />,
{ 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,
);
});
});

View File

@ -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)`
}
`;
function TemplateParamsEditor({
code = '{}',
language,
onChange = () => {},
}: {
code: string;
export type Props = {
queryEditor: QueryEditor;
language: 'yaml' | 'json';
onChange: () => void;
}) {
};
function TemplateParamsEditor({
queryEditor,
language,
onChange = () => {},
}: Props) {
const [parsedJSON, setParsedJSON] = useState({});
const [isValid, setIsValid] = useState(true);
const code = useSelector<SqlLabRootState, string>(
rootState =>
(getUpToDateQuery(rootState, queryEditor) as unknown as QueryEditor)
.templateParams || '{}',
);
useEffect(() => {
try {

View File

@ -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: {

View File

@ -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(

View File

@ -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 = {};

View File

@ -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', () => {

View File

@ -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<string, any>;
dbConnect: boolean;
offline: boolean;
queries: Query[];
queries: Record<string, Query>;
queryEditors: QueryEditor[];
tabHistory: string[]; // default is activeTab ? [activeTab.id.toString()] : []
tables: Record<string, any>[];
queriesLastUpdate: number;
user: UserWithPermissionsAndRoles;
errorMessage: string | null;
unsavedQueryEditor: Partial<QueryEditor>;
queryCostEstimates?: Record<string, QueryCostEstimate>;
};
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<string, any>[];
error: string;
}

View File

@ -17,9 +17,11 @@
* under the License.
*/
import { defaultQueryEditor } from 'src/SqlLab/fixtures';
import { newQueryTabName } from './newQueryTabName';
const emptyEditor = {
...defaultQueryEditor,
title: '',
schema: '',
autorun: false,

View File

@ -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;
}

View File

@ -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<any>) => void;
readOnly?: boolean;
schema?: string;