chore(sqllab): migrate to typescript

This commit is contained in:
justinpark 2023-12-04 14:26:43 -08:00
parent 2cd7135a51
commit 81f74703e7
16 changed files with 415 additions and 432 deletions

View File

@ -328,6 +328,7 @@ export type Query = {
actions: Record<string, any>;
type: DatasourceType;
columns: QueryColumn[];
runAsync?: boolean;
};
export type QueryResults = {

View File

@ -39,8 +39,8 @@ const common = { ...bootstrapData.common };
const user = { ...bootstrapData.user };
const noopReducer =
(initialState: unknown) =>
(state = initialState) =>
<STATE = unknown>(initialState: STATE) =>
(state: STATE = initialState) =>
state;
export default {

View File

@ -17,7 +17,7 @@
* under the License.
*/
import React from 'react';
import { combineReducers } from 'redux';
import { AnyAction, combineReducers } from 'redux';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { render } from 'spec/helpers/testing-library';
@ -38,18 +38,15 @@ jest.mock('src/SqlLab/components/QueryAutoRefresh', () => () => (
<div data-test="mock-query-auto-refresh" />
));
const sqlLabReducer = combineReducers(reducers);
const sqlLabReducer = combineReducers({
localStorageUsageInKilobytes: reducers.localStorageUsageInKilobytes,
});
const mockAction = {} as AnyAction;
describe('SqlLab App', () => {
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const store = mockStore(sqlLabReducer(undefined, {}), {});
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
const store = mockStore(sqlLabReducer(undefined, mockAction));
it('is valid', () => {
expect(React.isValidElement(<App />)).toBe(true);
@ -61,15 +58,13 @@ describe('SqlLab App', () => {
expect(getByTestId('mock-tabbed-sql-editors')).toBeInTheDocument();
});
it('logs current usage warning', () => {
it('logs current usage warning', async () => {
const localStorageUsageInKilobytes = LOCALSTORAGE_MAX_USAGE_KB + 10;
const initialState = {
localStorageUsageInKilobytes,
};
const storeExceedLocalStorage = mockStore(
sqlLabReducer(
{
localStorageUsageInKilobytes,
},
{},
),
sqlLabReducer(initialState, mockAction),
);
const { rerender } = render(<App />, {
@ -87,14 +82,14 @@ describe('SqlLab App', () => {
);
});
it('logs current local storage usage', () => {
it('logs current local storage usage', async () => {
const localStorageUsageInKilobytes = LOCALSTORAGE_MAX_USAGE_KB - 10;
const storeExceedLocalStorage = mockStore(
sqlLabReducer(
{
localStorageUsageInKilobytes,
},
{},
mockAction,
),
);

View File

@ -17,8 +17,6 @@
* under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { Redirect } from 'react-router-dom';
import { css, styled, t } from '@superset-ui/core';
@ -28,7 +26,8 @@ import {
LOCALSTORAGE_WARNING_THRESHOLD,
LOCALSTORAGE_WARNING_MESSAGE_THROTTLE_MS,
} from 'src/SqlLab/constants';
import * as Actions from 'src/SqlLab/actions/sqlLab';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import type { SqlLabRootState } from 'src/SqlLab/types';
import { logEvent } from 'src/logger/actions';
import {
LOG_ACTIONS_SQLLAB_WARN_LOCAL_STORAGE_USAGE,
@ -100,8 +99,21 @@ const SqlLabStyles = styled.div`
`};
`;
class App extends React.PureComponent {
constructor(props) {
type PureProps = {
// add this for testing componentDidUpdate spec
updated?: boolean;
};
type AppProps = ReturnType<typeof mergeProps> & PureProps;
interface AppState {
hash: string;
}
class App extends React.PureComponent<AppProps, AppState> {
hasLoggedLocalStorageUsage: boolean;
constructor(props: AppProps) {
super(props);
this.state = {
hash: window.location.hash,
@ -125,7 +137,7 @@ class App extends React.PureComponent {
componentDidUpdate() {
const { localStorageUsageInKilobytes, actions, queries } = this.props;
const queryCount = queries?.lenghth || 0;
const queryCount = Object.keys(queries || {}).length || 0;
if (
localStorageUsageInKilobytes >=
LOCALSTORAGE_WARNING_THRESHOLD * LOCALSTORAGE_MAX_USAGE_KB
@ -159,7 +171,7 @@ class App extends React.PureComponent {
this.setState({ hash: window.location.hash });
}
showLocalStorageUsageWarning(currentUsage, queryCount) {
showLocalStorageUsageWarning(currentUsage: number, queryCount: number) {
this.props.actions.addDangerToast(
t(
"SQL Lab uses your browser's local storage to store queries and results." +
@ -190,7 +202,6 @@ class App extends React.PureComponent {
<Redirect
to={{
pathname: '/sqllab/history/',
replace: true,
}}
/>
);
@ -207,13 +218,7 @@ class App extends React.PureComponent {
}
}
App.propTypes = {
actions: PropTypes.object,
common: PropTypes.object,
localStorageUsageInKilobytes: PropTypes.number.isRequired,
};
function mapStateToProps(state) {
function mapStateToProps(state: SqlLabRootState) {
const { common, localStorageUsageInKilobytes, sqlLab } = state;
return {
common,
@ -223,10 +228,21 @@ function mapStateToProps(state) {
};
}
function mapDispatchToProps(dispatch) {
const mapDispatchToProps = {
addDangerToast,
logEvent,
};
function mergeProps(
stateProps: ReturnType<typeof mapStateToProps>,
dispatchProps: typeof mapDispatchToProps,
state: PureProps,
) {
return {
actions: bindActionCreators({ ...Actions, logEvent }, dispatch),
...state,
...stateProps,
actions: dispatchProps,
};
}
export default connect(mapStateToProps, mapDispatchToProps)(App);
export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(App);

View File

@ -103,7 +103,7 @@ import SaveQuery, { QueryPayload } from '../SaveQuery';
import ScheduleQueryButton from '../ScheduleQueryButton';
import EstimateQueryCostButton from '../EstimateQueryCostButton';
import ShareSqlLabQuery from '../ShareSqlLabQuery';
import SqlEditorLeftBar, { ExtendedTable } from '../SqlEditorLeftBar';
import SqlEditorLeftBar from '../SqlEditorLeftBar';
import AceEditorWrapper from '../AceEditorWrapper';
import RunQueryActionButton from '../RunQueryActionButton';
import QueryLimitSelect from '../QueryLimitSelect';
@ -215,7 +215,6 @@ const StyledSqlEditor = styled.div`
const extensionsRegistry = getExtensionsRegistry();
export type Props = {
tables: ExtendedTable[];
queryEditor: QueryEditor;
defaultQueryLimit: number;
maxRow: number;
@ -235,7 +234,6 @@ const elementStyle = (
});
const SqlEditor: React.FC<Props> = ({
tables,
queryEditor,
defaultQueryLimit,
maxRow,
@ -839,7 +837,6 @@ const SqlEditor: React.FC<Props> = ({
<SqlEditorLeftBar
database={database}
queryEditorId={queryEditor.id}
tables={tables}
setEmptyState={bool => setShowEmptyState(bool)}
/>
</StyledSidebar>

View File

@ -20,16 +20,19 @@ import React from 'react';
import fetchMock from 'fetch-mock';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import '@testing-library/jest-dom/extend-expect';
import SqlEditorLeftBar from 'src/SqlLab/components/SqlEditorLeftBar';
import { table, initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
import { api } from 'src/hooks/apiResources/queryApi';
import { setupStore } from 'src/views/store';
import reducers from 'spec/helpers/reducerIndex';
import SqlEditorLeftBar, {
SqlEditorLeftBarProps,
} from 'src/SqlLab/components/SqlEditorLeftBar';
import {
table,
initialState,
defaultQueryEditor,
extraQueryEditor1,
} from 'src/SqlLab/fixtures';
import type { RootState } from 'src/views/store';
import type { Store } from 'redux';
const mockedProps = {
tables: [table],
queryEditorId: defaultQueryEditor.id,
database: {
id: 1,
@ -39,115 +42,117 @@ const mockedProps = {
height: 0,
};
let store;
let actions;
const logAction = () => next => action => {
if (typeof action === 'function') {
return next(action);
}
actions.push(action);
return next(action);
};
const createStore = initState =>
setupStore({
disableDegugger: true,
initialState: initState,
rootReducers: reducers,
middleware: getDefaultMiddleware =>
getDefaultMiddleware().concat(api.middleware, logAction),
});
beforeEach(() => {
store = createStore(initialState);
actions = [];
fetchMock.get('glob:*/api/v1/database/?*', { result: [] });
fetchMock.get('glob:*/api/v1/database/*/schemas/?*', {
count: 2,
result: ['main', 'new_schema'],
});
fetchMock.get('glob:*/api/v1/database/*/tables/*', {
count: 1,
count: 2,
result: [
{
label: 'ab_user',
value: 'ab_user',
},
{
label: 'new_table',
value: 'new_table',
},
],
});
fetchMock.get('glob:*/api/v1/database/*/table/*/*', {
status: 200,
body: {
columns: table.columns,
},
});
fetchMock.get('glob:*/api/v1/database/*/table_extra/*/*', {
status: 200,
body: {},
});
});
afterEach(() => {
fetchMock.restore();
store.dispatch(api.util.resetApiState());
jest.clearAllMocks();
});
const renderAndWait = (props, store) =>
const renderAndWait = (
props: SqlEditorLeftBarProps,
store?: Store,
initialState?: RootState,
) =>
waitFor(() =>
render(<SqlEditorLeftBar {...props} />, {
useRedux: true,
initialState,
...(store && { store }),
}),
);
test('is valid', () => {
expect(
React.isValidElement(
<Provider store={store}>
<SqlEditorLeftBar {...mockedProps} />
</Provider>,
),
).toBe(true);
});
test('renders a TableElement', async () => {
await renderAndWait(mockedProps, store);
expect(await screen.findByText(/Database/i)).toBeInTheDocument();
const tableElement = screen.getAllByTestId('table-element');
const { findByText, getAllByTestId } = await renderAndWait(
mockedProps,
undefined,
{ ...initialState, sqlLab: { ...initialState.sqlLab, tables: [table] } },
);
expect(await findByText(/Database/i)).toBeInTheDocument();
const tableElement = getAllByTestId('table-element');
expect(tableElement.length).toBeGreaterThanOrEqual(1);
});
test('table should be visible when expanded is true', async () => {
const { container } = await renderAndWait(mockedProps, store);
const { container, getByText, getByRole, queryAllByText } =
await renderAndWait(mockedProps, undefined, {
...initialState,
sqlLab: { ...initialState.sqlLab, tables: [table] },
});
const dbSelect = screen.getByRole('combobox', {
const dbSelect = getByRole('combobox', {
name: 'Select database or type to search databases',
});
const schemaSelect = screen.getByRole('combobox', {
const schemaSelect = getByRole('combobox', {
name: 'Select schema or type to search schemas',
});
const dropdown = screen.getByText(/Table/i);
const abUser = screen.queryAllByText(/ab_user/i);
const dropdown = getByText(/Table/i);
const abUser = queryAllByText(/ab_user/i);
await waitFor(() => {
expect(screen.getByText(/Database/i)).toBeInTheDocument();
expect(dbSelect).toBeInTheDocument();
expect(schemaSelect).toBeInTheDocument();
expect(dropdown).toBeInTheDocument();
expect(abUser).toHaveLength(2);
expect(
container.querySelector('.ant-collapse-content-active'),
).toBeInTheDocument();
expect(getByText(/Database/i)).toBeInTheDocument();
expect(dbSelect).toBeInTheDocument();
expect(schemaSelect).toBeInTheDocument();
expect(dropdown).toBeInTheDocument();
expect(abUser).toHaveLength(2);
expect(
container.querySelector('.ant-collapse-content-active'),
).toBeInTheDocument();
table.columns.forEach(({ name }) => {
expect(getByText(name)).toBeInTheDocument();
});
});
test('should toggle the table when the header is clicked', async () => {
await renderAndWait(mockedProps, store);
const { container } = await renderAndWait(mockedProps, undefined, {
...initialState,
sqlLab: { ...initialState.sqlLab, tables: [table] },
});
const header = (await screen.findAllByText(/ab_user/))[1];
const header = container.querySelector('.ant-collapse-header');
expect(header).toBeInTheDocument();
userEvent.click(header);
if (header) {
userEvent.click(header);
}
await waitFor(() => {
expect(actions[actions.length - 1].type).toEqual('COLLAPSE_TABLE');
});
await waitFor(() =>
expect(
container.querySelector('.ant-collapse-content-inactive'),
).toBeInTheDocument(),
);
});
test('When changing database the table list must be updated', async () => {
store = createStore({
const { rerender } = await renderAndWait(mockedProps, undefined, {
...initialState,
sqlLab: {
...initialState.sqlLab,
@ -155,9 +160,25 @@ test('When changing database the table list must be updated', async () => {
id: defaultQueryEditor.id,
schema: 'new_schema',
},
queryEditors: [
defaultQueryEditor,
{
...extraQueryEditor1,
schema: 'new_schema',
dbId: 2,
},
],
tables: [
table,
{
...table,
dbId: 2,
name: 'new_table',
queryEditorId: extraQueryEditor1.id,
},
],
},
});
const { rerender } = await renderAndWait(mockedProps, store);
expect(screen.getAllByText(/main/i)[0]).toBeInTheDocument();
expect(screen.getAllByText(/ab_user/i)[0]).toBeInTheDocument();
@ -170,21 +191,18 @@ test('When changing database the table list must be updated', async () => {
database_name: 'new_db',
backend: 'postgresql',
}}
queryEditorId={defaultQueryEditor.id}
tables={[{ ...mockedProps.tables[0], dbId: 2, name: 'new_table' }]}
queryEditorId={extraQueryEditor1.id}
/>,
{
useRedux: true,
store,
},
);
expect(await screen.findByText(/new_db/i)).toBeInTheDocument();
expect(await screen.findByText(/new_table/i)).toBeInTheDocument();
const updatedDbSelector = await screen.findAllByText(/new_db/i);
expect(updatedDbSelector[0]).toBeInTheDocument();
const updatedTableSelector = await screen.findAllByText(/new_table/i);
expect(updatedTableSelector[0]).toBeInTheDocument();
});
test('ignore schema api when current schema is deprecated', async () => {
const invalidSchemaName = 'None';
store = createStore({
await renderAndWait(mockedProps, undefined, {
...initialState,
sqlLab: {
...initialState.sqlLab,
@ -192,9 +210,9 @@ test('ignore schema api when current schema is deprecated', async () => {
id: defaultQueryEditor.id,
schema: invalidSchemaName,
},
tables: [table],
},
});
const { rerender } = await renderAndWait(mockedProps, store);
expect(await screen.findByText(/Database/i)).toBeInTheDocument();
expect(fetchMock.calls()).not.toContainEqual(
@ -204,7 +222,6 @@ test('ignore schema api when current schema is deprecated', async () => {
),
]),
);
rerender();
// Deselect the deprecated schema selection
await waitFor(() =>
expect(screen.queryByText(/None/i)).not.toBeInTheDocument(),

View File

@ -24,10 +24,10 @@ import React, {
Dispatch,
SetStateAction,
} from 'react';
import { useDispatch } from 'react-redux';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import querystring from 'query-string';
import { Table } from 'src/SqlLab/types';
import { SqlLabRootState, Table } from 'src/SqlLab/types';
import {
queryEditorSetDb,
addTable,
@ -55,16 +55,11 @@ import {
} from 'src/utils/localStorageHelpers';
import TableElement from '../TableElement';
export interface ExtendedTable extends Table {
expanded: boolean;
}
interface SqlEditorLeftBarProps {
export interface SqlEditorLeftBarProps {
queryEditorId: string;
height?: number;
tables?: ExtendedTable[];
database?: DatabaseObject;
setEmptyState: Dispatch<SetStateAction<boolean>>;
setEmptyState?: Dispatch<SetStateAction<boolean>>;
}
const StyledScrollbarContainer = styled.div`
@ -111,10 +106,14 @@ const LeftBarStyles = styled.div`
const SqlEditorLeftBar = ({
database,
queryEditorId,
tables = [],
height = 500,
setEmptyState,
}: SqlEditorLeftBarProps) => {
const tables = useSelector<SqlLabRootState, Table[]>(
({ sqlLab }) =>
sqlLab.tables.filter(table => table.queryEditorId === queryEditorId),
shallowEqual,
);
const dispatch = useDispatch();
const queryEditor = useQueryEditor(queryEditorId, ['dbId', 'schema']);
@ -144,7 +143,7 @@ const SqlEditorLeftBar = ({
};
const onDbChange = ({ id: dbId }: { id: number }) => {
setEmptyState(false);
setEmptyState?.(false);
dispatch(queryEditorSetDb(queryEditor, dbId));
};
@ -177,7 +176,7 @@ const SqlEditorLeftBar = ({
};
const onToggleTable = (updatedTables: string[]) => {
tables.forEach((table: ExtendedTable) => {
tables.forEach(table => {
if (!updatedTables.includes(table.id.toString()) && table.expanded) {
dispatch(collapseTable(table));
} else if (

View File

@ -1,228 +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 configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import URI from 'urijs';
import { Provider } from 'react-redux';
import { shallow, mount } from 'enzyme';
import { fireEvent, render, waitFor } from 'spec/helpers/testing-library';
import sinon from 'sinon';
import { act } from 'react-dom/test-utils';
import fetchMock from 'fetch-mock';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import { EditableTabs } from 'src/components/Tabs';
import TabbedSqlEditors from 'src/SqlLab/components/TabbedSqlEditors';
import SqlEditor from 'src/SqlLab/components/SqlEditor';
import { initialState } from 'src/SqlLab/fixtures';
import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName';
fetchMock.get('glob:*/api/v1/database/*', {});
fetchMock.get('glob:*/api/v1/saved_query/*', {});
fetchMock.get('glob:*/kv/*', {});
describe('TabbedSqlEditors', () => {
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const store = mockStore(initialState);
const queryEditors = [
{
autorun: false,
dbId: 1,
id: 'newEditorId',
latestQueryId: 'B1-VQU1zW',
schema: null,
selectedText: null,
sql: 'SELECT ds...',
name: 'Untitled Query',
},
];
const mockedProps = {
actions: {},
databases: {},
tables: [],
queries: {},
queryEditors: initialState.sqlLab.queryEditors,
tabHistory: initialState.sqlLab.tabHistory,
editorHeight: '',
getHeight: () => '100px',
database: {},
defaultQueryLimit: 1000,
maxRow: 100000,
};
const getWrapper = () =>
shallow(<TabbedSqlEditors store={store} {...mockedProps} />)
.dive()
.dive();
const mountWithAct = async () =>
act(async () => {
mount(
<Provider store={store}>
<TabbedSqlEditors {...mockedProps} />
</Provider>,
{
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
},
);
});
const setup = (props = {}, overridesStore) =>
render(<TabbedSqlEditors {...props} />, {
useRedux: true,
store: overridesStore || store,
});
let wrapper;
it('is valid', () => {
expect(React.isValidElement(<TabbedSqlEditors {...mockedProps} />)).toBe(
true,
);
});
describe('componentDidMount', () => {
let uriStub;
beforeEach(() => {
sinon.stub(window.history, 'replaceState');
uriStub = sinon.stub(URI.prototype, 'search');
});
afterEach(() => {
window.history.replaceState.restore();
uriStub.restore();
});
it('should handle id', async () => {
uriStub.returns({ id: 1 });
await mountWithAct();
expect(window.history.replaceState.getCall(0).args[2]).toBe('/sqllab');
});
it('should handle savedQueryId', async () => {
uriStub.returns({ savedQueryId: 1 });
await mountWithAct();
expect(window.history.replaceState.getCall(0).args[2]).toBe('/sqllab');
});
it('should handle sql', async () => {
uriStub.returns({ sql: 1, dbid: 1 });
await mountWithAct();
expect(window.history.replaceState.getCall(0).args[2]).toBe('/sqllab');
});
it('should handle custom url params', async () => {
uriStub.returns({
sql: 1,
dbid: 1,
custom_value: 'str',
extra_attr1: 'true',
});
await mountWithAct();
expect(window.history.replaceState.getCall(0).args[2]).toBe(
'/sqllab?custom_value=str&extra_attr1=true',
);
});
});
it('should removeQueryEditor', () => {
wrapper = getWrapper();
sinon.stub(wrapper.instance().props.actions, 'removeQueryEditor');
wrapper.instance().removeQueryEditor(queryEditors[0]);
expect(
wrapper.instance().props.actions.removeQueryEditor.getCall(0).args[0],
).toBe(queryEditors[0]);
});
it('should add new query editor', async () => {
const { getAllByLabelText } = setup(mockedProps);
fireEvent.click(getAllByLabelText('Add tab')[0]);
const actions = store.getActions();
await waitFor(() =>
expect(actions).toContainEqual({
type: 'ADD_QUERY_EDITOR',
queryEditor: expect.objectContaining({
name: expect.stringMatching(/Untitled Query (\d+)+/),
}),
}),
);
});
it('should properly increment query tab name', async () => {
const { getAllByLabelText } = setup(mockedProps);
const newTitle = newQueryTabName(store.getState().sqlLab.queryEditors);
fireEvent.click(getAllByLabelText('Add tab')[0]);
const actions = store.getActions();
await waitFor(() =>
expect(actions).toContainEqual({
type: 'ADD_QUERY_EDITOR',
queryEditor: expect.objectContaining({
name: newTitle,
}),
}),
);
});
it('should duplicate query editor', () => {
wrapper = getWrapper();
sinon.stub(wrapper.instance().props.actions, 'cloneQueryToNewTab');
wrapper.instance().duplicateQueryEditor(queryEditors[0]);
expect(
wrapper.instance().props.actions.cloneQueryToNewTab.getCall(0).args[0],
).toBe(queryEditors[0]);
});
it('should handle select', () => {
const mockEvent = {
target: {
getAttribute: () => null,
},
};
wrapper = getWrapper();
sinon.stub(wrapper.instance().props.actions, 'switchQueryEditor');
// cannot switch to current tab, switchQueryEditor never gets called
wrapper.instance().handleSelect('dfsadfs', mockEvent);
expect(
wrapper.instance().props.actions.switchQueryEditor.callCount,
).toEqual(0);
});
it('should handle add tab', () => {
wrapper = getWrapper();
sinon.spy(wrapper.instance(), 'newQueryEditor');
wrapper.instance().handleEdit('1', 'add');
expect(wrapper.instance().newQueryEditor.callCount).toBe(1);
wrapper.instance().newQueryEditor.restore();
});
it('should render', () => {
wrapper = getWrapper();
wrapper.setState({ hideLeftBar: true });
const firstTab = wrapper.find(EditableTabs.TabPane).first();
expect(firstTab.props()['data-key']).toContain(
initialState.sqlLab.queryEditors[0].id,
);
expect(firstTab.find(SqlEditor)).toHaveLength(1);
});
it('should disable new tab when offline', () => {
wrapper = getWrapper();
expect(wrapper.find('#a11y-query-editor-tabs').props().hideAdd).toBe(false);
wrapper.setProps({ offline: true });
expect(wrapper.find('#a11y-query-editor-tabs').props().hideAdd).toBe(true);
});
it('should have an empty state when query editors is empty', () => {
wrapper = getWrapper();
wrapper.setProps({ queryEditors: [] });
const firstTab = wrapper.find(EditableTabs.TabPane).first();
expect(firstTab.props()['data-key']).toBe(0);
});
});

View File

@ -0,0 +1,178 @@
/**
* 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 URI from 'urijs';
import { fireEvent, render, waitFor } from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import TabbedSqlEditors from 'src/SqlLab/components/TabbedSqlEditors';
import { initialState } from 'src/SqlLab/fixtures';
import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName';
import { Store } from 'redux';
import { RootState } from 'src/views/store';
import { SET_ACTIVE_QUERY_EDITOR } from 'src/SqlLab/actions/sqlLab';
fetchMock.get('glob:*/api/v1/database/*', {});
fetchMock.get('glob:*/api/v1/saved_query/*', {});
fetchMock.get('glob:*/kv/*', {});
jest.mock('src/SqlLab/components/SqlEditor', () => () => (
<div data-test="mock-sql-editor" />
));
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const store = mockStore(initialState);
const setup = (overridesStore?: Store, initialState?: RootState) =>
render(<TabbedSqlEditors />, {
useRedux: true,
initialState,
...(overridesStore && { store: overridesStore }),
});
beforeEach(() => {
store.clearActions();
});
describe('componentDidMount', () => {
let uriStub = jest.spyOn(URI.prototype, 'search');
let replaceState = jest.spyOn(window.history, 'replaceState');
beforeEach(() => {
replaceState = jest.spyOn(window.history, 'replaceState');
uriStub = jest.spyOn(URI.prototype, 'search');
});
afterEach(() => {
replaceState.mockReset();
uriStub.mockReset();
});
test('should handle id', () => {
uriStub.mockReturnValue({ id: 1 });
setup(store);
expect(replaceState).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
'/sqllab',
);
});
test('should handle savedQueryId', () => {
uriStub.mockReturnValue({ savedQueryId: 1 });
setup(store);
expect(replaceState).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
'/sqllab',
);
});
test('should handle sql', () => {
uriStub.mockReturnValue({ sql: 1, dbid: 1 });
setup(store);
expect(replaceState).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
'/sqllab',
);
});
test('should handle custom url params', () => {
uriStub.mockReturnValue({
sql: 1,
dbid: 1,
custom_value: 'str',
extra_attr1: 'true',
});
setup(store);
expect(replaceState).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
'/sqllab?custom_value=str&extra_attr1=true',
);
});
});
test('should removeQueryEditor', async () => {
const { getByRole, getAllByRole, queryByText } = setup(
undefined,
initialState,
);
const tabCount = getAllByRole('tab').length;
const tabList = getByRole('tablist');
const closeButton = tabList.getElementsByTagName('button')[0];
expect(closeButton).toBeInTheDocument();
if (closeButton) {
fireEvent.click(closeButton);
}
await waitFor(() => expect(getAllByRole('tab').length).toEqual(tabCount - 1));
expect(queryByText(initialState.sqlLab.queryEditors[0].name)).toBeFalsy();
});
test('should add new query editor', async () => {
const { getAllByLabelText, getAllByRole } = setup(undefined, initialState);
const tabCount = getAllByRole('tab').length;
fireEvent.click(getAllByLabelText('Add tab')[0]);
await waitFor(() => expect(getAllByRole('tab').length).toEqual(tabCount + 1));
expect(getAllByRole('tab')[tabCount]).toHaveTextContent(
/Untitled Query (\d+)+/,
);
});
test('should properly increment query tab name', async () => {
const { getAllByLabelText, getAllByRole } = setup(undefined, initialState);
const tabCount = getAllByRole('tab').length;
const newTitle = newQueryTabName(initialState.sqlLab.queryEditors);
fireEvent.click(getAllByLabelText('Add tab')[0]);
await waitFor(() => expect(getAllByRole('tab').length).toEqual(tabCount + 1));
expect(getAllByRole('tab')[tabCount]).toHaveTextContent(newTitle);
});
test('should handle select', async () => {
const { getAllByRole } = setup(store);
const tabs = getAllByRole('tab');
fireEvent.click(tabs[1]);
await waitFor(() => expect(store.getActions()).toHaveLength(1));
expect(store.getActions()[0]).toEqual(
expect.objectContaining({
type: SET_ACTIVE_QUERY_EDITOR,
queryEditor: initialState.sqlLab.queryEditors[1],
}),
);
});
test('should render', () => {
const { getAllByRole } = setup(store);
const tabs = getAllByRole('tab');
expect(tabs).toHaveLength(initialState.sqlLab.queryEditors.length);
});
test('should disable new tab when offline', () => {
const { queryAllByLabelText } = setup(undefined, {
...initialState,
sqlLab: {
...initialState.sqlLab,
offline: true,
},
});
expect(queryAllByLabelText('Add tab').length).toEqual(0);
});
test('should have an empty state when query editors is empty', () => {
const { getByText } = setup(undefined, {
...initialState,
sqlLab: {
...initialState.sqlLab,
queryEditors: [],
tabHistory: [],
},
});
expect(getByText('Add a new tab to create SQL Query')).toBeInTheDocument();
});

View File

@ -18,11 +18,10 @@
*/
import React from 'react';
import { pick } from 'lodash';
import PropTypes from 'prop-types';
import { EditableTabs } from 'src/components/Tabs';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import URI from 'urijs';
import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
import { FeatureFlag, styled, t, isFeatureEnabled } from '@superset-ui/core';
import { Tooltip } from 'src/components/Tooltip';
import { detectOS } from 'src/utils/common';
@ -33,22 +32,7 @@ import { locationContext } from 'src/pages/SqlLab/LocationContext';
import SqlEditor from '../SqlEditor';
import SqlEditorTabHeader from '../SqlEditorTabHeader';
const propTypes = {
actions: PropTypes.object.isRequired,
defaultDbId: PropTypes.number,
displayLimit: PropTypes.number,
defaultQueryLimit: PropTypes.number.isRequired,
maxRow: PropTypes.number.isRequired,
databases: PropTypes.object.isRequired,
queries: PropTypes.object.isRequired,
queryEditors: PropTypes.array,
tabHistory: PropTypes.array.isRequired,
tables: PropTypes.array.isRequired,
offline: PropTypes.bool,
saveQueryWarning: PropTypes.string,
scheduleQueryWarning: PropTypes.string,
};
const defaultProps = {
const DEFAULT_PROPS = {
queryEditors: [],
offline: false,
saveQueryWarning: null,
@ -73,15 +57,14 @@ const TabTitle = styled.span`
// Get the user's OS
const userOS = detectOS();
class TabbedSqlEditors extends React.PureComponent {
constructor(props) {
type TabbedSqlEditorsProps = ReturnType<typeof mergeProps>;
const SQL_LAB_URL = '/sqllab';
class TabbedSqlEditors extends React.PureComponent<TabbedSqlEditorsProps> {
constructor(props: TabbedSqlEditorsProps) {
super(props);
const sqlLabUrl = '/sqllab';
this.state = {
sqlLabUrl,
};
this.removeQueryEditor = this.removeQueryEditor.bind(this);
this.duplicateQueryEditor = this.duplicateQueryEditor.bind(this);
this.handleSelect = this.handleSelect.bind(this);
this.handleEdit = this.handleEdit.bind(this);
}
@ -136,7 +119,7 @@ class TabbedSqlEditors extends React.PureComponent {
...this.context.requestedQuery,
...bootstrapData.requested_query,
...queryParameters,
};
} as Record<string, string>;
// Popping a new tab based on the querystring
if (id || sql || savedQueryId || datasourceKey || queryId) {
@ -149,7 +132,7 @@ class TabbedSqlEditors extends React.PureComponent {
} else if (datasourceKey) {
this.props.actions.popDatasourceQuery(datasourceKey, sql);
} else if (sql) {
let databaseId = dbid;
let databaseId: string | number = dbid;
if (databaseId) {
databaseId = parseInt(databaseId, 10);
} else {
@ -177,11 +160,11 @@ class TabbedSqlEditors extends React.PureComponent {
this.newQueryEditor();
if (isNewQuery) {
window.history.replaceState({}, document.title, this.state.sqlLabUrl);
window.history.replaceState({}, document.title, SQL_LAB_URL);
}
} else {
const qe = this.activeQueryEditor();
const latestQuery = this.props.queries[qe.latestQueryId];
const latestQuery = this.props.queries[qe?.latestQueryId || ''];
if (
isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) &&
latestQuery &&
@ -197,9 +180,9 @@ class TabbedSqlEditors extends React.PureComponent {
}
}
popNewTab(urlParams) {
popNewTab(urlParams: Record<string, string>) {
// Clean the url in browser history
const updatedUrl = `${URI(this.state.sqlLabUrl).query(urlParams)}`;
const updatedUrl = `${URI(SQL_LAB_URL).query(urlParams)}`;
window.history.replaceState({}, document.title, updatedUrl);
}
@ -215,7 +198,7 @@ class TabbedSqlEditors extends React.PureComponent {
this.props.actions.addNewQueryEditor();
}
handleSelect(key) {
handleSelect(key: string) {
const qeid = this.props.tabHistory[this.props.tabHistory.length - 1];
if (key !== qeid) {
const queryEditor = this.props.queryEditors.find(qe => qe.id === key);
@ -229,24 +212,22 @@ class TabbedSqlEditors extends React.PureComponent {
}
}
handleEdit(key, action) {
handleEdit(key: string, action: string) {
if (action === 'remove') {
const qe = this.props.queryEditors.find(qe => qe.id === key);
this.removeQueryEditor(qe);
if (qe) {
this.removeQueryEditor(qe);
}
}
if (action === 'add') {
this.newQueryEditor();
}
}
removeQueryEditor(qe) {
removeQueryEditor(qe: QueryEditor) {
this.props.actions.removeQueryEditor(qe);
}
duplicateQueryEditor(qe) {
this.props.actions.cloneQueryToNewTab(qe, false);
}
render() {
const noQueryEditors = this.props.queryEditors?.length === 0;
const editors = this.props.queryEditors?.map(qe => (
@ -257,7 +238,6 @@ class TabbedSqlEditors extends React.PureComponent {
data-key={qe.id}
>
<SqlEditor
tables={this.props.tables.filter(xt => xt.queryEditorId === qe.id)}
queryEditor={qe}
defaultQueryLimit={this.props.defaultQueryLimit}
maxRow={this.props.maxRow}
@ -332,30 +312,45 @@ class TabbedSqlEditors extends React.PureComponent {
);
}
}
TabbedSqlEditors.propTypes = propTypes;
TabbedSqlEditors.defaultProps = defaultProps;
TabbedSqlEditors.contextType = locationContext;
function mapStateToProps({ sqlLab, common }) {
export function mapStateToProps({ sqlLab, common }: SqlLabRootState) {
return {
databases: sqlLab.databases,
queryEditors: sqlLab.queryEditors,
queryEditors: sqlLab.queryEditors ?? DEFAULT_PROPS.queryEditors,
queries: sqlLab.queries,
tabHistory: sqlLab.tabHistory,
tables: sqlLab.tables,
defaultDbId: common.conf.SQLLAB_DEFAULT_DBID,
displayLimit: common.conf.DISPLAY_MAX_ROW,
offline: sqlLab.offline,
offline: sqlLab.offline ?? DEFAULT_PROPS.offline,
defaultQueryLimit: common.conf.DEFAULT_SQLLAB_LIMIT,
maxRow: common.conf.SQL_MAX_ROW,
saveQueryWarning: common.conf.SQLLAB_SAVE_WARNING_MESSAGE,
scheduleQueryWarning: common.conf.SQLLAB_SCHEDULE_WARNING_MESSAGE,
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(Actions, dispatch),
saveQueryWarning:
common.conf.SQLLAB_SAVE_WARNING_MESSAGE ?? DEFAULT_PROPS.saveQueryWarning,
scheduleQueryWarning:
common.conf.SQLLAB_SCHEDULE_WARNING_MESSAGE ??
DEFAULT_PROPS.scheduleQueryWarning,
};
}
export default connect(mapStateToProps, mapDispatchToProps)(TabbedSqlEditors);
const mapDispatchToProps = {
...Actions,
};
function mergeProps(
stateProps: ReturnType<typeof mapStateToProps>,
dispatchProps: typeof mapDispatchToProps,
) {
return {
...stateProps,
actions: dispatchProps,
};
}
export default connect(
mapStateToProps,
mapDispatchToProps,
mergeProps,
)(TabbedSqlEditors);

View File

@ -35,7 +35,7 @@ export const alert = { bsStyle: 'danger', msg: 'Ooops', id: 'lksvmcx32' };
export const table = {
dbId: 1,
selectStar: 'SELECT * FROM ab_user',
queryEditorId: 'rJ-KP47a',
queryEditorId: 'dfsadfs',
schema: 'superset',
name: 'ab_user',
id: 'r11Vgt60',

View File

@ -16,11 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import { JsonObject, QueryResponse } from '@superset-ui/core';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { QueryResponse } from '@superset-ui/core';
import {
CommonBootstrapData,
UserWithPermissionsAndRoles,
} from 'src/types/bootstrapTypes';
import { ToastType } from 'src/components/MessageToasts/types';
import { DropdownButtonProps } from 'src/components/DropdownButton';
import { ButtonProps } from 'src/components/Button';
import type { TableMetaData } from 'src/hooks/apiResources';
export type QueryButtonProps = DropdownButtonProps | ButtonProps;
@ -81,8 +85,10 @@ export interface Table {
name: string;
queryEditorId: QueryEditor['id'];
dataPreviewQueryId: string | null;
expanded?: boolean;
expanded: boolean;
initialized?: boolean;
inLocalStorage?: boolean;
persistData?: TableMetaData;
}
export type SqlLabRootState = {
@ -92,7 +98,7 @@ export type SqlLabRootState = {
databases: Record<string, any>;
dbConnect: boolean;
offline: boolean;
queries: Record<string, QueryResponse>;
queries: Record<string, QueryResponse & { inLocalStorage?: boolean }>;
queryEditors: QueryEditor[];
tabHistory: string[]; // default is activeTab ? [activeTab.id.toString()] : []
tables: Table[];
@ -105,10 +111,7 @@ export type SqlLabRootState = {
localStorageUsageInKilobytes: number;
messageToasts: toastState[];
user: UserWithPermissionsAndRoles;
common: {
flash_messages: string[];
conf: JsonObject;
};
common: CommonBootstrapData;
};
export enum DatasetRadioState {

View File

@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { QueryResponse } from '@superset-ui/core';
import {
emptyQueryResults,
clearQueryEditors,
@ -43,7 +44,7 @@ describe('reduxStateToLocalStorageHelper', () => {
expect(Date.now() - startDttm).toBeGreaterThan(
LOCALSTORAGE_MAX_QUERY_AGE_MS,
);
expect(Object.keys(oldQuery.results)).toContain('data');
expect(Object.keys(oldQuery.results || {})).toContain('data');
const emptiedQuery = emptyQueryResults(queriesObj);
expect(emptiedQuery[id].startDttm).toBe(startDttm);
@ -55,7 +56,7 @@ describe('reduxStateToLocalStorageHelper', () => {
...queries[0],
startDttm: Date.now(),
results: { data: [{ a: 1 }] },
};
} as unknown as QueryResponse;
const largeQuery = {
...queries[1],
startDttm: Date.now(),
@ -70,7 +71,7 @@ describe('reduxStateToLocalStorageHelper', () => {
},
],
},
};
} as unknown as QueryResponse;
expect(Object.keys(largeQuery.results)).toContain('data');
const emptiedQuery = emptyQueryResults({
[reasonableSizeQuery.id]: reasonableSizeQuery,

View File

@ -16,6 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { QueryResponse } from '@superset-ui/core';
import type { QueryEditor, SqlLabRootState, Table } from 'src/SqlLab/types';
import type { ThunkDispatch } from 'redux-thunk';
import { pick } from 'lodash';
import { tableApiUtil } from 'src/hooks/apiResources/tables';
import {
@ -44,7 +47,7 @@ const PERSISTENT_QUERY_EDITOR_KEYS = new Set([
'hideLeftBar',
]);
function shouldEmptyQueryResults(query) {
function shouldEmptyQueryResults(query: QueryResponse) {
const { startDttm, results } = query;
return (
Date.now() - startDttm > LOCALSTORAGE_MAX_QUERY_AGE_MS ||
@ -53,7 +56,7 @@ function shouldEmptyQueryResults(query) {
);
}
export function emptyTablePersistData(tables) {
export function emptyTablePersistData(tables: Table[]) {
return tables
.map(table =>
pick(table, [
@ -68,7 +71,9 @@ export function emptyTablePersistData(tables) {
.filter(({ queryEditorId }) => Boolean(queryEditorId));
}
export function emptyQueryResults(queries) {
export function emptyQueryResults(
queries: SqlLabRootState['sqlLab']['queries'],
) {
return Object.keys(queries).reduce((accu, key) => {
const { results } = queries[key];
const query = {
@ -84,7 +89,7 @@ export function emptyQueryResults(queries) {
}, {});
}
export function clearQueryEditors(queryEditors) {
export function clearQueryEditors(queryEditors: QueryEditor[]) {
return queryEditors.map(editor =>
// only return selected keys
Object.keys(editor)
@ -99,7 +104,10 @@ export function clearQueryEditors(queryEditors) {
);
}
export function rehydratePersistedState(dispatch, state) {
export function rehydratePersistedState(
dispatch: ThunkDispatch<SqlLabRootState, unknown, any>,
state: SqlLabRootState,
) {
// Rehydrate server side persisted table metadata
state.sqlLab.tables.forEach(({ name: table, schema, dbId, persistData }) => {
if (dbId && schema && table && persistData?.columns) {

View File

@ -29,7 +29,7 @@ export enum EmptyStateSize {
}
export interface EmptyStateSmallProps {
title: ReactNode;
title?: ReactNode;
description?: ReactNode;
image?: ReactNode;
}

View File

@ -160,6 +160,7 @@ export interface BootstrapData {
embedded?: {
dashboard_id: string;
};
requested_query?: JsonObject;
}
export function isUser(user: any): user is User {