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

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

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

* revert ISaveableDatasource type cast

* missing trigger prop

* a default of an empty object and optional operator
This commit is contained in:
JUST.in DO IT
2022-08-23 08:17:19 -07:00
committed by GitHub
parent 4ca4a5c7cb
commit f77b910e2c
32 changed files with 1929 additions and 606 deletions

View File

@@ -0,0 +1,129 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { render, waitFor } from 'spec/helpers/testing-library';
import { QueryEditor } from 'src/SqlLab/types';
import { Store } from 'redux';
import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
import {
queryEditorSetSelectedText,
queryEditorSetFunctionNames,
addTable,
} from 'src/SqlLab/actions/sqlLab';
import AceEditorWrapper from 'src/SqlLab/components/AceEditorWrapper';
import { AsyncAceEditorProps } from 'src/components/AsyncAceEditor';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
jest.mock('src/components/Select', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));
jest.mock('src/components/Select/AsyncSelect', () => () => (
<div data-test="mock-deprecated-async-select" />
));
jest.mock('src/components/AsyncAceEditor', () => ({
FullSQLEditor: (props: AsyncAceEditorProps) => (
<div data-test="react-ace">{JSON.stringify(props)}</div>
),
}));
const setup = (queryEditor: QueryEditor, store?: Store) =>
render(
<AceEditorWrapper
queryEditor={queryEditor}
actions={{
queryEditorSetSelectedText,
queryEditorSetFunctionNames,
addTable,
}}
height="100px"
hotkeys={[]}
database={{}}
onChange={jest.fn()}
onBlur={jest.fn()}
autocomplete
/>,
{
useRedux: true,
...(store && { store }),
},
);
describe('AceEditorWrapper', () => {
it('renders ace editor including sql value', async () => {
const { getByTestId } = setup(defaultQueryEditor, mockStore(initialState));
await waitFor(() => expect(getByTestId('react-ace')).toBeInTheDocument());
expect(getByTestId('react-ace')).toHaveTextContent(
JSON.stringify({ value: defaultQueryEditor.sql }).slice(1, -1),
);
});
it('renders sql from unsaved change', () => {
const expectedSql = 'SELECT updated_column\nFROM updated_table\nWHERE';
const { getByTestId } = setup(
defaultQueryEditor,
mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: defaultQueryEditor.id,
sql: expectedSql,
},
},
}),
);
expect(getByTestId('react-ace')).toHaveTextContent(
JSON.stringify({ value: expectedSql }).slice(1, -1),
);
});
it('renders current sql for unrelated unsaved changes', () => {
const expectedSql = 'SELECT updated_column\nFROM updated_table\nWHERE';
const { getByTestId } = setup(
defaultQueryEditor,
mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: `${defaultQueryEditor.id}-other`,
sql: expectedSql,
},
},
}),
);
expect(getByTestId('react-ace')).not.toHaveTextContent(
JSON.stringify({ value: expectedSql }).slice(1, -1),
);
expect(getByTestId('react-ace')).toHaveTextContent(
JSON.stringify({ value: defaultQueryEditor.sql }).slice(1, -1),
);
});
});

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import React from 'react';
import { connect } from 'react-redux';
import { areArraysShallowEqual } from 'src/reduxUtils';
import sqlKeywords from 'src/SqlLab/utils/sqlKeywords';
import {
@@ -30,7 +31,7 @@ import {
AceCompleterKeyword,
FullSQLEditor as AceEditor,
} from 'src/components/AsyncAceEditor';
import { QueryEditor } from 'src/SqlLab/types';
import { QueryEditor, SchemaOption, SqlLabRootState } from 'src/SqlLab/types';
type HotKey = {
key: string;
@@ -39,7 +40,13 @@ type HotKey = {
func: () => void;
};
interface Props {
type OwnProps = {
queryEditor: QueryEditor;
extendedTables: Array<{ name: string; columns: any[] }>;
autocomplete: boolean;
onChange: (sql: string) => void;
onBlur: (sql: string) => void;
database: any;
actions: {
queryEditorSetSelectedText: (edit: any, text: null | string) => void;
queryEditorSetFunctionNames: (queryEditor: object, dbId: number) => void;
@@ -50,19 +57,19 @@ interface Props {
schema: any,
) => void;
};
autocomplete: boolean;
onBlur: (sql: string) => void;
hotkeys: HotKey[];
height: string;
};
type ReduxProps = {
queryEditor: QueryEditor;
sql: string;
database: any;
schemas: any[];
schemas: SchemaOption[];
tables: any[];
functionNames: string[];
extendedTables: Array<{ name: string; columns: any[] }>;
queryEditor: QueryEditor;
height: string;
hotkeys: HotKey[];
onChange: (sql: string) => void;
}
};
type Props = ReduxProps & OwnProps;
interface State {
sql: string;
@@ -286,4 +293,22 @@ class AceEditorWrapper extends React.PureComponent<Props, State> {
}
}
export default AceEditorWrapper;
function mapStateToProps(
{ sqlLab: { unsavedQueryEditor } }: SqlLabRootState,
{ queryEditor }: OwnProps,
) {
const currentQueryEditor = {
...queryEditor,
...(queryEditor.id === unsavedQueryEditor.id && unsavedQueryEditor),
};
return {
queryEditor: currentQueryEditor,
sql: currentQueryEditor.sql,
schemas: currentQueryEditor.schemaOptions || [],
tables: currentQueryEditor.tableOptions,
functionNames: currentQueryEditor.functionNames,
};
}
export default connect<ReduxProps, {}, OwnProps>(mapStateToProps)(
AceEditorWrapper,
);

View File

@@ -0,0 +1,93 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { render } from 'spec/helpers/testing-library';
import { Store } from 'redux';
import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
import EstimateQueryCostButton, {
EstimateQueryCostButtonProps,
} from 'src/SqlLab/components/EstimateQueryCostButton';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
jest.mock('src/components/Select', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));
jest.mock('src/components/Select/AsyncSelect', () => () => (
<div data-test="mock-deprecated-async-select" />
));
const setup = (props: Partial<EstimateQueryCostButtonProps>, store?: Store) =>
render(
<EstimateQueryCostButton
queryEditor={defaultQueryEditor}
getEstimate={jest.fn()}
{...props}
/>,
{
useRedux: true,
...(store && { store }),
},
);
describe('EstimateQueryCostButton', () => {
it('renders EstimateQueryCostButton', async () => {
const { queryByText } = setup({}, mockStore(initialState));
expect(queryByText('Estimate cost')).toBeTruthy();
});
it('renders label for selected query', async () => {
const queryEditorWithSelectedText = {
...defaultQueryEditor,
selectedText: 'SELECT',
};
const { queryByText } = setup(
{ queryEditor: queryEditorWithSelectedText },
mockStore(initialState),
);
expect(queryByText('Estimate selected query cost')).toBeTruthy();
});
it('renders label for selected query from unsaved', async () => {
const { queryByText } = setup(
{},
mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: defaultQueryEditor.id,
selectedText: 'SELECT',
},
},
}),
);
expect(queryByText('Estimate selected query cost')).toBeTruthy();
});
});

View File

@@ -24,23 +24,37 @@ import Button from 'src/components/Button';
import Loading from 'src/components/Loading';
import ModalTrigger from 'src/components/ModalTrigger';
import { EmptyWrapperType } from 'src/components/TableView/TableView';
import {
SqlLabRootState,
QueryCostEstimate,
QueryEditor,
} from 'src/SqlLab/types';
import { getUpToDateQuery } from 'src/SqlLab/actions/sqlLab';
import { useSelector } from 'react-redux';
interface EstimateQueryCostButtonProps {
export interface EstimateQueryCostButtonProps {
getEstimate: Function;
queryCostEstimate: Record<string, any>;
selectedText?: string;
queryEditor: QueryEditor;
tooltip?: string;
disabled?: boolean;
}
const EstimateQueryCostButton = ({
getEstimate,
queryCostEstimate = {},
selectedText,
queryEditor,
tooltip = '',
disabled = false,
}: EstimateQueryCostButtonProps) => {
const { cost } = queryCostEstimate;
const queryCostEstimate = useSelector<
SqlLabRootState,
QueryCostEstimate | undefined
>(state => state.sqlLab.queryCostEstimates?.[queryEditor.id]);
const selectedText = useSelector<SqlLabRootState, string | undefined>(
rootState =>
(getUpToDateQuery(rootState, queryEditor) as unknown as QueryEditor)
.selectedText,
);
const { cost } = queryCostEstimate || {};
const tableData = useMemo(() => (Array.isArray(cost) ? cost : []), [cost]);
const columns = useMemo(
() =>
@@ -57,16 +71,16 @@ const EstimateQueryCostButton = ({
};
const renderModalBody = () => {
if (queryCostEstimate.error !== null) {
if (queryCostEstimate?.error) {
return (
<Alert
key="query-estimate-error"
type="error"
message={queryCostEstimate.error}
message={queryCostEstimate?.error}
/>
);
}
if (queryCostEstimate.completed) {
if (queryCostEstimate?.completed) {
return (
<TableView
columns={columns}

View File

@@ -0,0 +1,137 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { Store } from 'redux';
import { render, fireEvent, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
import QueryLimitSelect, {
LIMIT_DROPDOWN,
QueryLimitSelectProps,
convertToNumWithSpaces,
} from 'src/SqlLab/components/QueryLimitSelect';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
jest.mock('src/components/Select', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));
jest.mock('src/components/Select/AsyncSelect', () => () => (
<div data-test="mock-deprecated-async-select" />
));
jest.mock('src/components/Icons/Icon', () => () => (
<div data-test="mock-icons-icon" />
));
const defaultQueryLimit = 100;
const setup = (props?: Partial<QueryLimitSelectProps>, store?: Store) =>
render(
<QueryLimitSelect
queryEditor={defaultQueryEditor}
maxRow={100000}
defaultQueryLimit={defaultQueryLimit}
{...props}
/>,
{
useRedux: true,
...(store && { store }),
},
);
describe('QueryLimitSelect', () => {
it('renders current query limit size', () => {
const queryLimit = 10;
const { getByText } = setup(
{
queryEditor: {
...defaultQueryEditor,
queryLimit,
},
},
mockStore(initialState),
);
expect(getByText(queryLimit)).toBeInTheDocument();
});
it('renders default query limit for initial queryEditor', () => {
const { getByText } = setup({}, mockStore(initialState));
expect(getByText(defaultQueryLimit)).toBeInTheDocument();
});
it('renders queryLimit from unsavedQueryEditor', () => {
const queryLimit = 10000;
const { getByText } = setup(
{},
mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: defaultQueryEditor.id,
queryLimit,
},
},
}),
);
expect(getByText(convertToNumWithSpaces(queryLimit))).toBeInTheDocument();
});
it('renders dropdown select', async () => {
const { baseElement, getByRole } = setup({}, mockStore(initialState));
const dropdown = baseElement.getElementsByClassName(
'ant-dropdown-trigger',
)[0];
userEvent.click(dropdown);
await waitFor(() => expect(getByRole('menu')).toBeInTheDocument());
});
it('dispatches QUERY_EDITOR_SET_QUERY_LIMIT action on dropdown menu click', async () => {
const store = mockStore(initialState);
const expectedIndex = 1;
const { baseElement, getAllByRole, getByRole } = setup({}, store);
const dropdown = baseElement.getElementsByClassName(
'ant-dropdown-trigger',
)[0];
userEvent.click(dropdown);
await waitFor(() => expect(getByRole('menu')).toBeInTheDocument());
const menu = getAllByRole('menuitem')[expectedIndex];
expect(store.getActions()).toEqual([]);
fireEvent.click(menu);
await waitFor(() =>
expect(store.getActions()).toEqual([
{
type: 'QUERY_EDITOR_SET_QUERY_LIMIT',
queryLimit: LIMIT_DROPDOWN[expectedIndex],
queryEditor: defaultQueryEditor,
},
]),
);
});
});

View File

@@ -0,0 +1,118 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { styled, useTheme } from '@superset-ui/core';
import { AntdDropdown } from 'src/components';
import { Menu } from 'src/components/Menu';
import Icons from 'src/components/Icons';
import { SqlLabRootState, QueryEditor } from 'src/SqlLab/types';
import { queryEditorSetQueryLimit } from 'src/SqlLab/actions/sqlLab';
export interface QueryLimitSelectProps {
queryEditor: QueryEditor;
maxRow: number;
defaultQueryLimit: number;
}
export const LIMIT_DROPDOWN = [10, 100, 1000, 10000, 100000];
export function convertToNumWithSpaces(num: number) {
return num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1 ');
}
const LimitSelectStyled = styled.span`
${({ theme }) => `
.ant-dropdown-trigger {
align-items: center;
color: ${theme.colors.grayscale.dark2};
display: flex;
font-size: 12px;
margin-right: ${theme.gridUnit * 2}px;
text-decoration: none;
border: 0;
background: transparent;
span {
display: inline-block;
margin-right: ${theme.gridUnit * 2}px;
&:last-of-type: {
margin-right: ${theme.gridUnit * 4}px;
}
}
}
`}
`;
function renderQueryLimit(
maxRow: number,
setQueryLimit: (limit: number) => void,
) {
// Adding SQL_MAX_ROW value to dropdown
LIMIT_DROPDOWN.push(maxRow);
return (
<Menu>
{[...new Set(LIMIT_DROPDOWN)].map(limit => (
<Menu.Item key={`${limit}`} onClick={() => setQueryLimit(limit)}>
{/* // eslint-disable-line no-use-before-define */}
<a role="button">{convertToNumWithSpaces(limit)}</a>{' '}
</Menu.Item>
))}
</Menu>
);
}
const QueryLimitSelect = ({
queryEditor,
maxRow,
defaultQueryLimit,
}: QueryLimitSelectProps) => {
const queryLimit = useSelector<SqlLabRootState, number>(
({ sqlLab: { unsavedQueryEditor } }) => {
const updatedQueryEditor = {
...queryEditor,
...(unsavedQueryEditor.id === queryEditor.id && unsavedQueryEditor),
};
return updatedQueryEditor.queryLimit || defaultQueryLimit;
},
);
const dispatch = useDispatch();
const setQueryLimit = (updatedQueryLimit: number) =>
dispatch(queryEditorSetQueryLimit(queryEditor, updatedQueryLimit));
const theme = useTheme();
return (
<LimitSelectStyled>
<AntdDropdown
overlay={renderQueryLimit(maxRow, setQueryLimit)}
trigger={['click']}
>
<button type="button" onClick={e => e.preventDefault()}>
<span>LIMIT:</span>
<span className="limitDropdown">
{convertToNumWithSpaces(queryLimit)}
</span>
<Icons.TriangleDown iconColor={theme.colors.grayscale.base} />
</button>
</AntdDropdown>
</LimitSelectStyled>
);
};
export default QueryLimitSelect;

View File

@@ -1,53 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import RunQueryActionButton from 'src/SqlLab/components/RunQueryActionButton';
import Button from 'src/components/Button';
describe('RunQueryActionButton', () => {
let wrapper;
const defaultProps = {
allowAsync: false,
dbId: 1,
queryState: 'pending',
runQuery: () => {}, // eslint-disable-line
selectedText: null,
stopQuery: () => {}, // eslint-disable-line
sql: '',
};
beforeEach(() => {
wrapper = mount(<RunQueryActionButton {...defaultProps} />, {
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
});
});
it('is a valid react element', () => {
expect(
React.isValidElement(<RunQueryActionButton {...defaultProps} />),
).toBe(true);
});
it('renders a single Button', () => {
expect(wrapper.find(Button)).toExist();
});
});

View File

@@ -0,0 +1,151 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { Store } from 'redux';
import { render, fireEvent, waitFor } from 'spec/helpers/testing-library';
import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
import RunQueryActionButton, {
Props,
} from 'src/SqlLab/components/RunQueryActionButton';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
jest.mock('src/components/Select', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));
jest.mock('src/components/Select/AsyncSelect', () => () => (
<div data-test="mock-deprecated-async-select" />
));
const defaultProps = {
queryEditor: defaultQueryEditor,
allowAsync: false,
dbId: 1,
queryState: 'ready',
runQuery: jest.fn(),
selectedText: null,
stopQuery: jest.fn(),
overlayCreateAsMenu: null,
};
const setup = (props?: Partial<Props>, store?: Store) =>
render(<RunQueryActionButton {...defaultProps} {...props} />, {
useRedux: true,
...(store && { store }),
});
describe('RunQueryActionButton', () => {
beforeEach(() => {
defaultProps.runQuery.mockReset();
defaultProps.stopQuery.mockReset();
});
it('renders a single Button', () => {
const { getByRole } = setup({}, mockStore(initialState));
expect(getByRole('button')).toBeInTheDocument();
});
it('renders a label for Run Query', () => {
const { getByText } = setup({}, mockStore(initialState));
expect(getByText('Run')).toBeInTheDocument();
});
it('renders a label for Selected Query', () => {
const { getByText } = setup(
{},
mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: defaultQueryEditor.id,
selectedText: 'FROM',
},
},
}),
);
expect(getByText('Run selection')).toBeInTheDocument();
});
it('disable button when sql from unsaved changes is empty', () => {
const { getByRole } = setup(
{},
mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: defaultQueryEditor.id,
sql: '',
},
},
}),
);
const button = getByRole('button');
expect(button).toBeDisabled();
});
it('enable default button for unrelated unsaved changes', () => {
const { getByRole } = setup(
{},
mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: `${defaultQueryEditor.id}-other`,
sql: '',
},
},
}),
);
const button = getByRole('button');
expect(button).toBeEnabled();
});
it('dispatch runQuery on click', async () => {
const { getByRole } = setup({}, mockStore(initialState));
const button = getByRole('button');
expect(defaultProps.runQuery).toHaveBeenCalledTimes(0);
fireEvent.click(button);
await waitFor(() => expect(defaultProps.runQuery).toHaveBeenCalledTimes(1));
});
describe('on running state', () => {
it('dispatch stopQuery on click', async () => {
const { getByRole } = setup(
{ queryState: 'running' },
mockStore(initialState),
);
const button = getByRole('button');
expect(defaultProps.stopQuery).toHaveBeenCalledTimes(0);
fireEvent.click(button);
await waitFor(() =>
expect(defaultProps.stopQuery).toHaveBeenCalledTimes(1),
);
});
});
});

View File

@@ -24,15 +24,20 @@ import Button from 'src/components/Button';
import Icons from 'src/components/Icons';
import { DropdownButton } from 'src/components/DropdownButton';
import { detectOS } from 'src/utils/common';
import { QueryButtonProps } from 'src/SqlLab/types';
import { shallowEqual, useSelector } from 'react-redux';
import {
QueryEditor,
SqlLabRootState,
QueryButtonProps,
} from 'src/SqlLab/types';
import { getUpToDateQuery } from 'src/SqlLab/actions/sqlLab';
interface Props {
export interface Props {
queryEditor: QueryEditor;
allowAsync: boolean;
queryState?: string;
runQuery: (c?: boolean) => void;
selectedText?: string;
stopQuery: () => void;
sql: string;
overlayCreateAsMenu: typeof Menu | null;
}
@@ -83,16 +88,27 @@ const StyledButton = styled.span`
const RunQueryActionButton = ({
allowAsync = false,
queryEditor,
queryState,
selectedText,
sql = '',
overlayCreateAsMenu,
runQuery,
stopQuery,
}: Props) => {
const theme = useTheme();
const userOS = detectOS();
const { selectedText, sql } = useSelector<
SqlLabRootState,
Pick<QueryEditor, 'selectedText' | 'sql'>
>(rootState => {
const currentQueryEditor = getUpToDateQuery(
rootState,
queryEditor,
) as unknown as QueryEditor;
return {
selectedText: currentQueryEditor.selectedText,
sql: currentQueryEditor.sql,
};
}, shallowEqual);
const shouldShowStopBtn =
!!queryState && ['running', 'pending'].indexOf(queryState) > -1;
@@ -101,7 +117,7 @@ const RunQueryActionButton = ({
? (DropdownButton as React.FC)
: Button;
const isDisabled = !sql.trim();
const isDisabled = !sql || !sql.trim();
const stopButtonTooltipText = useMemo(
() =>

View File

@@ -17,18 +17,19 @@
* under the License.
*/
import React from 'react';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import SaveQuery from 'src/SqlLab/components/SaveQuery';
import { databases } from 'src/SqlLab/fixtures';
import { initialState, databases } from 'src/SqlLab/fixtures';
const mockedProps = {
query: {
queryEditor: {
dbId: 1,
schema: 'main',
sql: 'SELECT * FROM t',
},
defaultLabel: 'untitled',
animation: false,
database: databases.result[0],
onUpdate: () => {},
@@ -43,9 +44,15 @@ const splitSaveBtnProps = {
},
};
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
describe('SavedQuery', () => {
it('renders a non-split save button when allows_virtual_table_explore is not enabled', () => {
render(<SaveQuery {...mockedProps} />, { useRedux: true });
render(<SaveQuery {...mockedProps} />, {
useRedux: true,
store: mockStore(initialState),
});
const saveBtn = screen.getByRole('button', { name: /save/i });
@@ -53,7 +60,10 @@ describe('SavedQuery', () => {
});
it('renders a save query modal when user clicks save button', () => {
render(<SaveQuery {...mockedProps} />, { useRedux: true });
render(<SaveQuery {...mockedProps} />, {
useRedux: true,
store: mockStore(initialState),
});
const saveBtn = screen.getByRole('button', { name: /save/i });
userEvent.click(saveBtn);
@@ -66,7 +76,10 @@ describe('SavedQuery', () => {
});
it('renders the save query modal UI', () => {
render(<SaveQuery {...mockedProps} />, { useRedux: true });
render(<SaveQuery {...mockedProps} />, {
useRedux: true,
store: mockStore(initialState),
});
const saveBtn = screen.getByRole('button', { name: /save/i });
userEvent.click(saveBtn);
@@ -100,12 +113,15 @@ describe('SavedQuery', () => {
it('renders a "save as new" and "update" button if query already exists', () => {
const props = {
...mockedProps,
query: {
queryEditor: {
...mockedProps.query,
remoteId: '42',
},
};
render(<SaveQuery {...props} />, { useRedux: true });
render(<SaveQuery {...props} />, {
useRedux: true,
store: mockStore(initialState),
});
const saveBtn = screen.getByRole('button', { name: /save/i });
userEvent.click(saveBtn);
@@ -118,7 +134,10 @@ describe('SavedQuery', () => {
});
it('renders a split save button when allows_virtual_table_explore is enabled', async () => {
render(<SaveQuery {...splitSaveBtnProps} />, { useRedux: true });
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(initialState),
});
await waitFor(() => {
const saveBtn = screen.getByRole('button', { name: /save/i });
@@ -130,7 +149,10 @@ describe('SavedQuery', () => {
});
it('renders a save dataset modal when user clicks "save dataset" menu item', async () => {
render(<SaveQuery {...splitSaveBtnProps} />, { useRedux: true });
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(initialState),
});
await waitFor(() => {
const caretBtn = screen.getByRole('button', { name: /caret-down/i });
@@ -146,7 +168,10 @@ describe('SavedQuery', () => {
});
it('renders the save dataset modal UI', async () => {
render(<SaveQuery {...splitSaveBtnProps} />, { useRedux: true });
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,
store: mockStore(initialState),
});
await waitFor(() => {
const caretBtn = screen.getByRole('button', { name: /caret-down/i });

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import React, { useState, useEffect } from 'react';
import { useSelector, shallowEqual } from 'react-redux';
import { Row, Col } from 'src/components';
import { Input, TextArea } from 'src/components/Input';
import { t, styled } from '@superset-ui/core';
@@ -25,12 +26,16 @@ import { Menu } from 'src/components/Menu';
import { Form, FormItem } from 'src/components/Form';
import Modal from 'src/components/Modal';
import SaveDatasetActionButton from 'src/SqlLab/components/SaveDatasetActionButton';
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
import {
SaveDatasetModal,
ISaveableDatasource,
} from 'src/SqlLab/components/SaveDatasetModal';
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
interface SaveQueryProps {
query: QueryPayload;
defaultLabel: string;
queryEditor: QueryEditor;
columns: ISaveableDatasource['columns'];
onSave: (arg0: QueryPayload) => void;
onUpdate: (arg0: QueryPayload) => void;
saveQueryWarning: string | null;
@@ -76,13 +81,22 @@ const Styles = styled.span`
`;
export default function SaveQuery({
query,
defaultLabel = t('Undefined'),
queryEditor,
onSave = () => {},
onUpdate,
saveQueryWarning = null,
database,
columns,
}: SaveQueryProps) {
const query = useSelector<SqlLabRootState, QueryEditor>(
({ sqlLab: { unsavedQueryEditor } }) => ({
...queryEditor,
...(queryEditor.id === unsavedQueryEditor.id && unsavedQueryEditor),
columns,
}),
shallowEqual,
);
const defaultLabel = query.name || query.description || t('Undefined');
const [description, setDescription] = useState<string>(
query.description || '',
);
@@ -100,11 +114,12 @@ export default function SaveQuery({
</Menu>
);
const queryPayload = () => ({
...query,
name: label,
description,
});
const queryPayload = () =>
({
...query,
name: label,
description,
} as any as QueryPayload);
useEffect(() => {
if (!isSaved) setLabel(defaultLabel);

View File

@@ -40,7 +40,12 @@ import {
} from 'src/SqlLab/actions/sqlLab';
import { EmptyStateBig } from 'src/components/EmptyState';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import { initialState, queries, table } from 'src/SqlLab/fixtures';
import {
initialState,
queries,
table,
defaultQueryEditor,
} from 'src/SqlLab/fixtures';
const MOCKED_SQL_EDITOR_HEIGHT = 500;
@@ -48,7 +53,31 @@ fetchMock.get('glob:*/api/v1/database/*', { result: [] });
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const store = mockStore(initialState);
const store = mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
databases: {
dbid1: {
allow_ctas: false,
allow_cvas: false,
allow_dml: false,
allow_file_upload: false,
allow_multi_schema_metadata_fetch: false,
allow_run_async: false,
backend: 'postgresql',
database_name: 'examples',
expose_in_sqllab: true,
force_ctas_schema: null,
id: 1,
},
},
unsavedQueryEditor: {
id: defaultQueryEditor.id,
dbId: 'dbid1',
},
},
});
describe('SqlEditor', () => {
const mockedProps = {
@@ -57,21 +86,9 @@ describe('SqlEditor', () => {
queryEditorSetSelectedText,
queryEditorSetSchemaOptions,
addDangerToast: jest.fn(),
removeDataPreview: jest.fn(),
},
database: {
allow_ctas: false,
allow_cvas: false,
allow_dml: false,
allow_file_upload: false,
allow_multi_schema_metadata_fetch: false,
allow_run_async: false,
backend: 'postgresql',
database_name: 'examples',
expose_in_sqllab: true,
force_ctas_schema: null,
id: 1,
},
queryEditorId: initialState.sqlLab.queryEditors[0].id,
queryEditor: initialState.sqlLab.queryEditors[0],
latestQuery: queries[0],
tables: [table],
getHeight: () => '100px',
@@ -94,8 +111,8 @@ describe('SqlEditor', () => {
);
it('does not render SqlEditor if no db selected', () => {
const database = {};
const updatedProps = { ...mockedProps, database };
const queryEditor = initialState.sqlLab.queryEditors[1];
const updatedProps = { ...mockedProps, queryEditor };
const wrapper = buildWrapper(updatedProps);
expect(wrapper.find(EmptyStateBig)).toExist();
});

View File

@@ -43,10 +43,10 @@ import {
persistEditorHeight,
postStopQuery,
queryEditorSetAutorun,
queryEditorSetQueryLimit,
queryEditorSetSql,
queryEditorSetAndSaveSql,
queryEditorSetTemplateParams,
runQueryFromSqlEditor,
runQuery,
saveQuery,
addSavedQueryToTabState,
@@ -79,8 +79,8 @@ import SqlEditorLeftBar from '../SqlEditorLeftBar';
import AceEditorWrapper from '../AceEditorWrapper';
import RunQueryActionButton from '../RunQueryActionButton';
import { newQueryTabName } from '../../utils/newQueryTabName';
import QueryLimitSelect from '../QueryLimitSelect';
const LIMIT_DROPDOWN = [10, 100, 1000, 10000, 100000];
const SQL_EDITOR_PADDING = 10;
const INITIAL_NORTH_PERCENT = 30;
const INITIAL_SOUTH_PERCENT = 70;
@@ -96,26 +96,6 @@ const validatorMap =
bootstrapData?.common?.conf?.SQL_VALIDATORS_BY_ENGINE || {};
const scheduledQueriesConf = bootstrapData?.common?.conf?.SCHEDULED_QUERIES;
const LimitSelectStyled = styled.span`
${({ theme }) => `
.ant-dropdown-trigger {
align-items: center;
color: ${theme.colors.grayscale.dark2};
display: flex;
font-size: 12px;
margin-right: ${theme.gridUnit * 2}px;
text-decoration: none;
span {
display: inline-block;
margin-right: ${theme.gridUnit * 2}px;
&:last-of-type: {
margin-right: ${theme.gridUnit * 4}px;
}
}
}
`}
`;
const StyledToolbar = styled.div`
padding: ${({ theme }) => theme.gridUnit * 2}px;
background: ${({ theme }) => theme.colors.grayscale.light5};
@@ -154,7 +134,7 @@ const propTypes = {
tables: PropTypes.array.isRequired,
editorQueries: PropTypes.array.isRequired,
dataPreviewQueries: PropTypes.array.isRequired,
queryEditorId: PropTypes.string.isRequired,
queryEditor: PropTypes.object.isRequired,
hideLeftBar: PropTypes.bool,
defaultQueryLimit: PropTypes.number.isRequired,
maxRow: PropTypes.number.isRequired,
@@ -205,7 +185,6 @@ class SqlEditor extends React.PureComponent {
);
this.queryPane = this.queryPane.bind(this);
this.getHotkeyConfig = this.getHotkeyConfig.bind(this);
this.renderQueryLimit = this.renderQueryLimit.bind(this);
this.getAceEditorAndSouthPaneHeights =
this.getAceEditorAndSouthPaneHeights.bind(this);
this.getSqlEditorHeight = this.getSqlEditorHeight.bind(this);
@@ -382,21 +361,10 @@ class SqlEditor extends React.PureComponent {
this.props.queryEditorSetAndSaveSql(this.props.queryEditor, sql);
}
setQueryLimit(queryLimit) {
this.props.queryEditorSetQueryLimit(this.props.queryEditor, queryLimit);
}
getQueryCostEstimate() {
if (this.props.database) {
const qe = this.props.queryEditor;
const query = {
dbId: qe.dbId,
sql: qe.selectedText ? qe.selectedText : this.props.queryEditor.sql,
sqlEditorId: qe.id,
schema: qe.schema,
templateParams: qe.templateParams,
};
this.props.estimateQueryCost(query);
this.props.estimateQueryCost(qe);
}
}
@@ -425,16 +393,9 @@ class SqlEditor extends React.PureComponent {
}
requestValidation(sql) {
if (this.props.database) {
const qe = this.props.queryEditor;
const query = {
dbId: qe.dbId,
sql,
sqlEditorId: qe.id,
schema: qe.schema,
templateParams: qe.templateParams,
};
this.props.validateQuery(query);
const { database, queryEditor, validateQuery } = this.props;
if (database) {
validateQuery(queryEditor, sql);
}
}
@@ -458,25 +419,22 @@ class SqlEditor extends React.PureComponent {
}
startQuery(ctas = false, ctas_method = CtasEnum.TABLE) {
const qe = this.props.queryEditor;
const query = {
dbId: qe.dbId,
sql: qe.selectedText ? qe.selectedText : qe.sql,
sqlEditorId: qe.id,
tab: qe.name,
schema: qe.schema,
tempTable: ctas ? this.state.ctas : '',
templateParams: qe.templateParams,
queryLimit: qe.queryLimit || this.props.defaultQueryLimit,
runAsync: this.props.database
? this.props.database.allow_run_async
: false,
const {
database,
runQueryFromSqlEditor,
setActiveSouthPaneTab,
queryEditor,
defaultQueryLimit,
} = this.props;
runQueryFromSqlEditor(
database,
queryEditor,
defaultQueryLimit,
ctas ? this.state.ctas : '',
ctas,
ctas_method,
updateTabState: !qe.selectedText,
};
this.props.runQuery(query);
this.props.setActiveSouthPaneTab('Results');
);
setActiveSouthPaneTab('Results');
}
stopQuery() {
@@ -529,11 +487,7 @@ class SqlEditor extends React.PureComponent {
onBlur={this.setQueryEditorSql}
onChange={this.onSqlChanged}
queryEditor={this.props.queryEditor}
sql={this.props.queryEditor.sql}
database={this.props.database}
schemas={this.props.queryEditor.schemaOptions}
tables={this.props.queryEditor.tableOptions}
functionNames={this.props.queryEditor.functionNames}
extendedTables={this.props.tables}
height={`${aceEditorHeight}px`}
hotkeys={hotkeys}
@@ -577,7 +531,7 @@ class SqlEditor extends React.PureComponent {
onChange={params => {
this.props.actions.queryEditorSetTemplateParams(qe, params);
}}
code={qe.templateParams}
queryEditor={qe}
/>
</Menu.Item>
)}
@@ -599,25 +553,6 @@ class SqlEditor extends React.PureComponent {
);
}
renderQueryLimit() {
// Adding SQL_MAX_ROW value to dropdown
const { maxRow } = this.props;
LIMIT_DROPDOWN.push(maxRow);
return (
<Menu>
{[...new Set(LIMIT_DROPDOWN)].map(limit => (
<Menu.Item key={`${limit}`} onClick={() => this.setQueryLimit(limit)}>
{/* // eslint-disable-line no-use-before-define */}
<a role="button" styling="link">
{this.convertToNumWithSpaces(limit)}
</a>{' '}
</Menu.Item>
))}
</Menu>
);
}
async saveQuery(query) {
const { queryEditor: qe, actions } = this.props;
const savedQuery = await actions.saveQuery(query);
@@ -673,11 +608,10 @@ class SqlEditor extends React.PureComponent {
? this.props.database.allow_run_async
: false
}
queryEditor={qe}
queryState={this.props.latestQuery?.state}
runQuery={this.runQuery}
selectedText={qe.selectedText}
stopQuery={this.stopQuery}
sql={this.props.queryEditor.sql}
overlayCreateAsMenu={showMenu ? runMenuBtn : null}
/>
</span>
@@ -687,27 +621,17 @@ class SqlEditor extends React.PureComponent {
<span>
<EstimateQueryCostButton
getEstimate={this.getQueryCostEstimate}
queryCostEstimate={qe.queryCostEstimate}
selectedText={qe.selectedText}
queryEditor={qe}
tooltip={t('Estimate the cost before running a query')}
/>
</span>
)}
<span>
<LimitSelectStyled>
<AntdDropdown overlay={this.renderQueryLimit()} trigger="click">
<a onClick={e => e.preventDefault()}>
<span>LIMIT:</span>
<span className="limitDropdown">
{this.convertToNumWithSpaces(
this.props.queryEditor.queryLimit ||
this.props.defaultQueryLimit,
)}
</span>
<Icons.TriangleDown iconColor={theme.colors.grayscale.base} />
</a>
</AntdDropdown>
</LimitSelectStyled>
<QueryLimitSelect
queryEditor={this.props.queryEditor}
maxRow={this.props.maxRow}
defaultQueryLimit={this.props.defaultQueryLimit}
/>
</span>
{this.props.latestQuery && (
<Timer
@@ -721,11 +645,8 @@ class SqlEditor extends React.PureComponent {
<div className="rightItems">
<span>
<SaveQuery
query={{
...qe,
columns: this.props.latestQuery?.results?.columns || [],
}}
defaultLabel={qe.name || qe.description}
queryEditor={qe}
columns={this.props.latestQuery?.results?.columns || []}
onSave={this.saveQuery}
onUpdate={this.props.actions.updateSavedQuery}
saveQueryWarning={this.props.saveQueryWarning}
@@ -832,12 +753,22 @@ class SqlEditor extends React.PureComponent {
SqlEditor.defaultProps = defaultProps;
SqlEditor.propTypes = propTypes;
function mapStateToProps({ sqlLab }, props) {
const queryEditor = sqlLab.queryEditors.find(
editor => editor.id === props.queryEditorId,
);
function mapStateToProps({ sqlLab }, { queryEditor }) {
let { latestQueryId, dbId } = queryEditor;
if (sqlLab.unsavedQueryEditor.id === queryEditor.id) {
const { latestQueryId: unsavedQID, dbId: unsavedDBID } =
sqlLab.unsavedQueryEditor;
latestQueryId = unsavedQID || latestQueryId;
dbId = unsavedDBID || dbId;
}
const database = sqlLab.databases[dbId];
const latestQuery = sqlLab.queries[latestQueryId];
return { sqlLab, ...props, queryEditor, queryEditors: sqlLab.queryEditors };
return {
queryEditors: sqlLab.queryEditors,
latestQuery,
database,
};
}
function mapDispatchToProps(dispatch) {
@@ -848,10 +779,10 @@ function mapDispatchToProps(dispatch) {
persistEditorHeight,
postStopQuery,
queryEditorSetAutorun,
queryEditorSetQueryLimit,
queryEditorSetSql,
queryEditorSetAndSaveSql,
queryEditorSetTemplateParams,
runQueryFromSqlEditor,
runQuery,
saveQuery,
addSavedQueryToTabState,

View File

@@ -32,7 +32,7 @@ import Collapse from 'src/components/Collapse';
import Icons from 'src/components/Icons';
import { TableSelectorMultiple } from 'src/components/TableSelector';
import { IconTooltip } from 'src/components/IconTooltip';
import { QueryEditor } from 'src/SqlLab/types';
import { QueryEditor, SchemaOption } from 'src/SqlLab/types';
import { DatabaseObject } from 'src/components/DatabaseSelector';
import { EmptyStateSmall } from 'src/components/EmptyState';
import {
@@ -55,7 +55,10 @@ interface actionsTypes {
setDatabases: (arg0: any) => {};
addDangerToast: (msg: string) => void;
queryEditorSetSchema: (queryEditor: QueryEditor, schema?: string) => void;
queryEditorSetSchemaOptions: () => void;
queryEditorSetSchemaOptions: (
queryEditor: QueryEditor,
options: SchemaOption[],
) => void;
queryEditorSetTableOptions: (
queryEditor: QueryEditor,
options: Array<any>,
@@ -70,7 +73,6 @@ interface SqlEditorLeftBarProps {
actions: actionsTypes & TableElementProps['actions'];
database: DatabaseObject;
setEmptyState: Dispatch<SetStateAction<boolean>>;
showDisabled: boolean;
}
const StyledScrollbarContainer = styled.div`
@@ -239,6 +241,15 @@ export default function SqlEditorLeftBar({
[actions],
);
const handleSchemasLoad = React.useCallback(
(options: Array<any>) => {
if (queryEditorRef.current) {
actions.queryEditorSetSchemaOptions(queryEditorRef.current, options);
}
},
[actions],
);
return (
<div className="SqlEditorLeftBar">
<TableSelectorMultiple
@@ -249,7 +260,7 @@ export default function SqlEditorLeftBar({
handleError={actions.addDangerToast}
onDbChange={onDbChange}
onSchemaChange={handleSchemaChange}
onSchemasLoad={actions.queryEditorSetSchemaOptions}
onSchemasLoad={handleSchemasLoad}
onTableSelectChange={onTablesChange}
onTablesLoad={handleTablesLoad}
schema={queryEditor.schema}

View File

@@ -0,0 +1,220 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {
fireEvent,
screen,
render,
waitFor,
} from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { QueryEditor } from 'src/SqlLab/types';
import {
initialState,
defaultQueryEditor,
extraQueryEditor1,
extraQueryEditor2,
} from 'src/SqlLab/fixtures';
import { Store } from 'redux';
import {
REMOVE_QUERY_EDITOR,
QUERY_EDITOR_SET_TITLE,
ADD_QUERY_EDITOR,
QUERY_EDITOR_TOGGLE_LEFT_BAR,
} from 'src/SqlLab/actions/sqlLab';
import SqlEditorTabHeader from 'src/SqlLab/components/SqlEditorTabHeader';
jest.mock('src/components/Select', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));
jest.mock('src/components/Select/AsyncSelect', () => () => (
<div data-test="mock-async-select" />
));
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const setup = (queryEditor: QueryEditor, store?: Store) =>
render(<SqlEditorTabHeader queryEditor={queryEditor} />, {
useRedux: true,
...(store && { store }),
});
describe('SqlEditorTabHeader', () => {
it('renders name', () => {
const { queryByText } = setup(defaultQueryEditor, mockStore(initialState));
expect(queryByText(defaultQueryEditor.name)).toBeTruthy();
expect(queryByText(extraQueryEditor1.name)).toBeFalsy();
expect(queryByText(extraQueryEditor2.name)).toBeFalsy();
});
it('renders name from unsaved changes', () => {
const expectedTitle = 'updated title';
const { queryByText } = setup(
defaultQueryEditor,
mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: defaultQueryEditor.id,
name: expectedTitle,
},
},
}),
);
expect(queryByText(expectedTitle)).toBeTruthy();
expect(queryByText(defaultQueryEditor.name)).toBeFalsy();
expect(queryByText(extraQueryEditor1.name)).toBeFalsy();
expect(queryByText(extraQueryEditor2.name)).toBeFalsy();
});
it('renders current name for unrelated unsaved changes', () => {
const unrelatedTitle = 'updated title';
const { queryByText } = setup(
defaultQueryEditor,
mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: `${defaultQueryEditor.id}-other`,
name: unrelatedTitle,
},
},
}),
);
expect(queryByText(defaultQueryEditor.name)).toBeTruthy();
expect(queryByText(unrelatedTitle)).toBeFalsy();
expect(queryByText(extraQueryEditor1.name)).toBeFalsy();
expect(queryByText(extraQueryEditor2.name)).toBeFalsy();
});
describe('with dropdown menus', () => {
let store = mockStore();
beforeEach(async () => {
store = mockStore(initialState);
const { getByTestId } = setup(defaultQueryEditor, store);
const dropdown = getByTestId('dropdown-trigger');
userEvent.click(dropdown);
});
it('should dispatch removeQueryEditor action', async () => {
await waitFor(() =>
expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('close-tab-menu-option'));
const actions = store.getActions();
await waitFor(() =>
expect(actions[0]).toEqual({
type: REMOVE_QUERY_EDITOR,
queryEditor: defaultQueryEditor,
}),
);
});
it('should dispatch queryEditorSetTitle action', async () => {
await waitFor(() =>
expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
);
const expectedTitle = 'typed text';
const mockPrompt = jest
.spyOn(window, 'prompt')
.mockImplementation(() => expectedTitle);
fireEvent.click(screen.getByTestId('rename-tab-menu-option'));
const actions = store.getActions();
await waitFor(() =>
expect(actions[0]).toEqual({
type: QUERY_EDITOR_SET_TITLE,
name: expectedTitle,
queryEditor: expect.objectContaining({
id: defaultQueryEditor.id,
}),
}),
);
mockPrompt.mockClear();
});
it('should dispatch toggleLeftBar action', async () => {
await waitFor(() =>
expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('toggle-menu-option'));
const actions = store.getActions();
await waitFor(() =>
expect(actions[0]).toEqual({
type: QUERY_EDITOR_TOGGLE_LEFT_BAR,
hideLeftBar: !defaultQueryEditor.hideLeftBar,
queryEditor: expect.objectContaining({
id: defaultQueryEditor.id,
}),
}),
);
});
it('should dispatch removeAllOtherQueryEditors action', async () => {
await waitFor(() =>
expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('close-all-other-menu-option'));
const actions = store.getActions();
await waitFor(() =>
expect(actions).toEqual([
{
type: REMOVE_QUERY_EDITOR,
queryEditor: initialState.sqlLab.queryEditors[1],
},
{
type: REMOVE_QUERY_EDITOR,
queryEditor: initialState.sqlLab.queryEditors[2],
},
]),
);
});
it('should dispatch cloneQueryToNewTab action', async () => {
await waitFor(() =>
expect(screen.getByTestId('close-tab-menu-option')).toBeInTheDocument(),
);
fireEvent.click(screen.getByTestId('clone-tab-menu-option'));
const actions = store.getActions();
await waitFor(() =>
expect(actions[0]).toEqual({
type: ADD_QUERY_EDITOR,
queryEditor: expect.objectContaining({
name: `Copy of ${defaultQueryEditor.name}`,
sql: defaultQueryEditor.sql,
autorun: false,
}),
}),
);
});
});
});

View File

@@ -0,0 +1,147 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useMemo } from 'react';
import { bindActionCreators } from 'redux';
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { Dropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu';
import { styled, t, QueryState } from '@superset-ui/core';
import {
removeQueryEditor,
removeAllOtherQueryEditors,
queryEditorSetTitle,
cloneQueryToNewTab,
toggleLeftBar,
} from 'src/SqlLab/actions/sqlLab';
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
import TabStatusIcon from '../TabStatusIcon';
const TabTitleWrapper = styled.div`
display: flex;
align-items: center;
`;
const TabTitle = styled.span`
margin-right: ${({ theme }) => theme.gridUnit * 2}px;
text-transform: none;
`;
interface Props {
queryEditor: QueryEditor;
}
const SqlEditorTabHeader: React.FC<Props> = ({ queryEditor }) => {
const qe = useSelector<SqlLabRootState, QueryEditor>(
({ sqlLab: { unsavedQueryEditor } }) => ({
...queryEditor,
...(queryEditor.id === unsavedQueryEditor.id && unsavedQueryEditor),
}),
shallowEqual,
);
const queryStatus = useSelector<SqlLabRootState, QueryState>(
({ sqlLab }) => sqlLab.queries[qe.latestQueryId || '']?.state || '',
);
const dispatch = useDispatch();
const actions = useMemo(
() =>
bindActionCreators(
{
removeQueryEditor,
removeAllOtherQueryEditors,
queryEditorSetTitle,
cloneQueryToNewTab,
toggleLeftBar,
},
dispatch,
),
[dispatch],
);
function renameTab() {
const newTitle = prompt(t('Enter a new title for the tab'));
if (newTitle) {
actions.queryEditorSetTitle(qe, newTitle);
}
}
return (
<TabTitleWrapper>
<Dropdown
trigger={['click']}
overlay={
<Menu style={{ width: 176 }}>
<Menu.Item
className="close-btn"
key="1"
onClick={() => actions.removeQueryEditor(qe)}
data-test="close-tab-menu-option"
>
<div className="icon-container">
<i className="fa fa-close" />
</div>
{t('Close tab')}
</Menu.Item>
<Menu.Item
key="2"
onClick={renameTab}
data-test="rename-tab-menu-option"
>
<div className="icon-container">
<i className="fa fa-i-cursor" />
</div>
{t('Rename tab')}
</Menu.Item>
<Menu.Item
key="3"
onClick={() => actions.toggleLeftBar(qe)}
data-test="toggle-menu-option"
>
<div className="icon-container">
<i className="fa fa-cogs" />
</div>
{qe.hideLeftBar ? t('Expand tool bar') : t('Hide tool bar')}
</Menu.Item>
<Menu.Item
key="4"
onClick={() => actions.removeAllOtherQueryEditors(qe)}
data-test="close-all-other-menu-option"
>
<div className="icon-container">
<i className="fa fa-times-circle-o" />
</div>
{t('Close all other tabs')}
</Menu.Item>
<Menu.Item
key="5"
onClick={() => actions.cloneQueryToNewTab(qe, false)}
data-test="clone-tab-menu-option"
>
<div className="icon-container">
<i className="fa fa-files-o" />
</div>
{t('Duplicate tab')}
</Menu.Item>
</Menu>
}
/>
<TabTitle>{qe.name}</TabTitle> <TabStatusIcon tabState={queryStatus} />{' '}
</TabTitleWrapper>
);
};
export default SqlEditorTabHeader;

View File

@@ -30,6 +30,7 @@ import { EditableTabs } from 'src/components/Tabs';
import TabbedSqlEditors from 'src/SqlLab/components/TabbedSqlEditors';
import SqlEditor from 'src/SqlLab/components/SqlEditor';
import { table, initialState } from 'src/SqlLab/fixtures';
import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName';
fetchMock.get('glob:*/api/v1/database/*', {});
fetchMock.get('glob:*/savedqueryviewapi/api/get/*', {});
@@ -150,18 +151,6 @@ describe('TabbedSqlEditors', () => {
);
});
});
it('should rename Tab', () => {
global.prompt = () => 'new title';
wrapper = getWrapper();
sinon.stub(wrapper.instance().props.actions, 'queryEditorSetTitle');
wrapper.instance().renameTab(queryEditors[0]);
expect(
wrapper.instance().props.actions.queryEditorSetTitle.getCall(0).args[1],
).toBe('new title');
delete global.prompt;
});
it('should removeQueryEditor', () => {
wrapper = getWrapper();
sinon.stub(wrapper.instance().props.actions, 'removeQueryEditor');
@@ -183,11 +172,11 @@ describe('TabbedSqlEditors', () => {
it('should properly increment query tab name', () => {
wrapper = getWrapper();
sinon.stub(wrapper.instance().props.actions, 'addQueryEditor');
const newTitle = newQueryTabName(wrapper.instance().props.queryEditors);
wrapper.instance().newQueryEditor();
expect(
wrapper.instance().props.actions.addQueryEditor.getCall(0).args[0].name,
).toContain('Untitled Query 2');
).toContain(newTitle);
});
it('should duplicate query editor', () => {
wrapper = getWrapper();

View File

@@ -18,9 +18,7 @@
*/
import React from 'react';
import PropTypes from 'prop-types';
import { Dropdown } from 'src/components/Dropdown';
import { EditableTabs } from 'src/components/Tabs';
import { Menu } from 'src/components/Menu';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import URI from 'urijs';
@@ -33,7 +31,7 @@ import * as Actions from 'src/SqlLab/actions/sqlLab';
import { EmptyStateBig } from 'src/components/EmptyState';
import { newQueryTabName } from '../../utils/newQueryTabName';
import SqlEditor from '../SqlEditor';
import TabStatusIcon from '../TabStatusIcon';
import SqlEditorTabHeader from '../SqlEditorTabHeader';
const propTypes = {
actions: PropTypes.object.isRequired,
@@ -44,7 +42,6 @@ const propTypes = {
databases: PropTypes.object.isRequired,
queries: PropTypes.object.isRequired,
queryEditors: PropTypes.array,
requestedQuery: PropTypes.object,
tabHistory: PropTypes.array.isRequired,
tables: PropTypes.array.isRequired,
offline: PropTypes.bool,
@@ -54,16 +51,10 @@ const propTypes = {
const defaultProps = {
queryEditors: [],
offline: false,
requestedQuery: null,
saveQueryWarning: null,
scheduleQueryWarning: null,
};
const TabTitleWrapper = styled.div`
display: flex;
align-items: center;
`;
const StyledTab = styled.span`
line-height: 24px;
`;
@@ -86,10 +77,6 @@ class TabbedSqlEditors extends React.PureComponent {
dataPreviewQueries: [],
};
this.removeQueryEditor = this.removeQueryEditor.bind(this);
this.renameTab = this.renameTab.bind(this);
this.toggleLeftBar = this.toggleLeftBar.bind(this);
this.removeAllOtherQueryEditors =
this.removeAllOtherQueryEditors.bind(this);
this.duplicateQueryEditor = this.duplicateQueryEditor.bind(this);
this.handleSelect = this.handleSelect.bind(this);
this.handleEdit = this.handleEdit.bind(this);
@@ -236,14 +223,6 @@ class TabbedSqlEditors extends React.PureComponent {
window.history.replaceState({}, document.title, this.state.sqlLabUrl);
}
renameTab(qe) {
/* eslint no-alert: 0 */
const newTitle = prompt(t('Enter a new title for the tab'));
if (newTitle) {
this.props.actions.queryEditorSetTitle(qe, newTitle);
}
}
activeQueryEditor() {
if (this.props.tabHistory.length === 0) {
return this.props.queryEditors[0];
@@ -304,106 +283,34 @@ class TabbedSqlEditors extends React.PureComponent {
this.props.actions.removeQueryEditor(qe);
}
removeAllOtherQueryEditors(cqe) {
this.props.queryEditors.forEach(
qe => qe !== cqe && this.removeQueryEditor(qe),
);
}
duplicateQueryEditor(qe) {
this.props.actions.cloneQueryToNewTab(qe, false);
}
toggleLeftBar(qe) {
this.props.actions.toggleLeftBar(qe);
}
render() {
const noQueryEditors = this.props.queryEditors?.length === 0;
const editors = this.props.queryEditors.map(qe => {
let latestQuery;
if (qe.latestQueryId) {
latestQuery = this.props.queries[qe.latestQueryId];
}
let database;
if (qe.dbId) {
database = this.props.databases[qe.dbId];
}
const state = latestQuery ? latestQuery.state : '';
const menu = (
<Menu style={{ width: 176 }}>
<Menu.Item
className="close-btn"
key="1"
onClick={() => this.removeQueryEditor(qe)}
data-test="close-tab-menu-option"
>
<div className="icon-container">
<i className="fa fa-close" />
</div>
{t('Close tab')}
</Menu.Item>
<Menu.Item key="2" onClick={() => this.renameTab(qe)}>
<div className="icon-container">
<i className="fa fa-i-cursor" />
</div>
{t('Rename tab')}
</Menu.Item>
<Menu.Item key="3" onClick={() => this.toggleLeftBar(qe)}>
<div className="icon-container">
<i className="fa fa-cogs" />
</div>
{qe.hideLeftBar ? t('Expand tool bar') : t('Hide tool bar')}
</Menu.Item>
<Menu.Item
key="4"
onClick={() => this.removeAllOtherQueryEditors(qe)}
>
<div className="icon-container">
<i className="fa fa-times-circle-o" />
</div>
{t('Close all other tabs')}
</Menu.Item>
<Menu.Item key="5" onClick={() => this.duplicateQueryEditor(qe)}>
<div className="icon-container">
<i className="fa fa-files-o" />
</div>
{t('Duplicate tab')}
</Menu.Item>
</Menu>
);
const tabHeader = (
<TabTitleWrapper>
<Dropdown overlay={menu} trigger={['click']} />
<TabTitle>{qe.name}</TabTitle> <TabStatusIcon tabState={state} />{' '}
</TabTitleWrapper>
);
return (
<EditableTabs.TabPane
key={qe.id}
tab={tabHeader}
// for tests - key prop isn't handled by enzyme well bcs it's a react keyword
data-key={qe.id}
>
<SqlEditor
tables={this.props.tables.filter(xt => xt.queryEditorId === qe.id)}
queryEditorId={qe.id}
editorQueries={this.state.queriesArray}
dataPreviewQueries={this.state.dataPreviewQueries}
latestQuery={latestQuery}
database={database}
actions={this.props.actions}
hideLeftBar={qe.hideLeftBar}
defaultQueryLimit={this.props.defaultQueryLimit}
maxRow={this.props.maxRow}
displayLimit={this.props.displayLimit}
saveQueryWarning={this.props.saveQueryWarning}
scheduleQueryWarning={this.props.scheduleQueryWarning}
/>
</EditableTabs.TabPane>
);
});
const editors = this.props.queryEditors?.map(qe => (
<EditableTabs.TabPane
key={qe.id}
tab={<SqlEditorTabHeader queryEditor={qe} />}
// for tests - key prop isn't handled by enzyme well bcs it's a react keyword
data-key={qe.id}
>
<SqlEditor
tables={this.props.tables.filter(xt => xt.queryEditorId === qe.id)}
queryEditor={qe}
editorQueries={this.state.queriesArray}
dataPreviewQueries={this.state.dataPreviewQueries}
actions={this.props.actions}
hideLeftBar={qe.hideLeftBar}
defaultQueryLimit={this.props.defaultQueryLimit}
maxRow={this.props.maxRow}
displayLimit={this.props.displayLimit}
saveQueryWarning={this.props.saveQueryWarning}
scheduleQueryWarning={this.props.scheduleQueryWarning}
/>
</EditableTabs.TabPane>
));
const emptyTab = (
<StyledTab>
@@ -472,7 +379,7 @@ class TabbedSqlEditors extends React.PureComponent {
TabbedSqlEditors.propTypes = propTypes;
TabbedSqlEditors.defaultProps = defaultProps;
function mapStateToProps({ sqlLab, common, requestedQuery }) {
function mapStateToProps({ sqlLab, common }) {
return {
databases: sqlLab.databases,
queryEditors: sqlLab.queryEditors,
@@ -486,7 +393,6 @@ function mapStateToProps({ sqlLab, common, requestedQuery }) {
maxRow: common.conf.SQL_MAX_ROW,
saveQueryWarning: common.conf.SQLLAB_SAVE_WARNING_MESSAGE,
scheduleQueryWarning: common.conf.SQLLAB_SCHEDULE_WARNING_MESSAGE,
requestedQuery,
};
}
function mapDispatchToProps(dispatch) {

View File

@@ -17,38 +17,100 @@
* under the License.
*/
import React, { ReactNode } from 'react';
import React from 'react';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { Store } from 'redux';
import {
render,
fireEvent,
getByText,
waitFor,
} from 'spec/helpers/testing-library';
import { ThemeProvider, supersetTheme } from '@superset-ui/core';
import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
import TemplateParamsEditor from 'src/SqlLab/components/TemplateParamsEditor';
import TemplateParamsEditor, {
Props,
} from 'src/SqlLab/components/TemplateParamsEditor';
const ThemeWrapper = ({ children }: { children: ReactNode }) => (
<ThemeProvider theme={supersetTheme}>{children}</ThemeProvider>
);
jest.mock('src/components/Select', () => () => (
<div data-test="mock-deprecated-select" />
));
jest.mock('src/components/Select/Select', () => () => (
<div data-test="mock-deprecated-select-select" />
));
jest.mock('src/components/Select/AsyncSelect', () => () => (
<div data-test="mock-async-select" />
));
jest.mock('src/components/AsyncAceEditor', () => ({
ConfigEditor: ({ value }: { value: string }) => (
<div data-test="mock-async-ace-editor">{value}</div>
),
}));
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const setup = (otherProps: Partial<Props> = {}, store?: Store) =>
render(
<TemplateParamsEditor
language="json"
onChange={() => {}}
queryEditor={defaultQueryEditor}
{...otherProps}
/>,
{
useRedux: true,
store: mockStore(initialState),
...(store && { store }),
},
);
describe('TemplateParamsEditor', () => {
it('should render with a title', () => {
const { container } = render(
<TemplateParamsEditor code="FOO" language="json" onChange={() => {}} />,
{ wrapper: ThemeWrapper },
);
const { container } = setup();
expect(container.querySelector('div[role="button"]')).toBeInTheDocument();
});
it('should open a modal with the ace editor', async () => {
const { container, baseElement } = render(
<TemplateParamsEditor code="FOO" language="json" onChange={() => {}} />,
{ wrapper: ThemeWrapper },
const { container, getByTestId } = setup();
fireEvent.click(getByText(container, 'Parameters'));
await waitFor(() => {
expect(getByTestId('mock-async-ace-editor')).toBeInTheDocument();
});
});
it('renders templateParams', async () => {
const { container, getByTestId } = setup();
fireEvent.click(getByText(container, 'Parameters'));
await waitFor(() => {
expect(getByTestId('mock-async-ace-editor')).toBeInTheDocument();
});
expect(getByTestId('mock-async-ace-editor')).toHaveTextContent(
defaultQueryEditor.templateParams,
);
});
it('renders code from unsaved changes', async () => {
const expectedCode = 'custom code value';
const { container, getByTestId } = setup(
{},
mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
unsavedQueryEditor: {
id: defaultQueryEditor.id,
templateParams: expectedCode,
},
},
}),
);
fireEvent.click(getByText(container, 'Parameters'));
await waitFor(() => {
expect(baseElement.querySelector('#ace-editor')).toBeInTheDocument();
expect(getByTestId('mock-async-ace-editor')).toBeInTheDocument();
});
expect(getByTestId('mock-async-ace-editor')).toHaveTextContent(
expectedCode,
);
});
});

View File

@@ -26,6 +26,9 @@ import ModalTrigger from 'src/components/ModalTrigger';
import { ConfigEditor } from 'src/components/AsyncAceEditor';
import { FAST_DEBOUNCE } from 'src/constants';
import { Tooltip } from 'src/components/Tooltip';
import { useSelector } from 'react-redux';
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
import { getUpToDateQuery } from 'src/SqlLab/actions/sqlLab';
const StyledConfigEditor = styled(ConfigEditor)`
&.ace_editor {
@@ -33,17 +36,24 @@ const StyledConfigEditor = styled(ConfigEditor)`
}
`;
function TemplateParamsEditor({
code = '{}',
language,
onChange = () => {},
}: {
code: string;
export type Props = {
queryEditor: QueryEditor;
language: 'yaml' | 'json';
onChange: () => void;
}) {
};
function TemplateParamsEditor({
queryEditor,
language,
onChange = () => {},
}: Props) {
const [parsedJSON, setParsedJSON] = useState({});
const [isValid, setIsValid] = useState(true);
const code = useSelector<SqlLabRootState, string>(
rootState =>
(getUpToDateQuery(rootState, queryEditor) as unknown as QueryEditor)
.templateParams || '{}',
);
useEffect(() => {
try {