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:
parent
4ca4a5c7cb
commit
f77b910e2c
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { defaultQueryEditor } from 'src/SqlLab/fixtures';
|
||||
import { newQueryTabName } from './newQueryTabName';
|
||||
|
||||
const emptyEditor = {
|
||||
...defaultQueryEditor,
|
||||
title: '',
|
||||
schema: '',
|
||||
autorun: false,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue