diff --git a/superset-frontend/spec/javascripts/sqllab/QuerySearch_spec.jsx b/superset-frontend/spec/javascripts/sqllab/QuerySearch_spec.jsx
index f3fbf92571cc..c650a4cdafe2 100644
--- a/superset-frontend/spec/javascripts/sqllab/QuerySearch_spec.jsx
+++ b/superset-frontend/spec/javascripts/sqllab/QuerySearch_spec.jsx
@@ -17,77 +17,121 @@
* under the License.
*/
import React from 'react';
-import Button from 'src/components/Button';
-import { shallow } from 'enzyme';
-import sinon from 'sinon';
+import thunk from 'redux-thunk';
+import configureStore from 'redux-mock-store';
import fetchMock from 'fetch-mock';
-import Select from 'src/components/Select';
import QuerySearch from 'src/SqlLab/components/QuerySearch';
+import { Provider } from 'react-redux';
+import { supersetTheme, ThemeProvider } from '@superset-ui/core';
+import { fireEvent, render, screen, act } from '@testing-library/react';
+import '@testing-library/jest-dom/extend-expect';
+import userEvent from '@testing-library/user-event';
+
+const mockStore = configureStore([thunk]);
+const store = mockStore({});
const SEARCH_ENDPOINT = 'glob:*/superset/search_queries?*';
+const USER_ENDPOINT = 'glob:*/api/v1/query/related/user';
+const DATABASE_ENDPOINT = 'glob:*/api/v1/database/?*';
fetchMock.get(SEARCH_ENDPOINT, []);
+fetchMock.get(USER_ENDPOINT, []);
+fetchMock.get(DATABASE_ENDPOINT, []);
describe('QuerySearch', () => {
- const search = sinon.spy(QuerySearch.prototype, 'refreshQueries');
const mockedProps = {
actions: { addDangerToast: jest.fn() },
- height: 0,
displayLimit: 50,
};
+
it('is valid', () => {
- expect(React.isValidElement()).toBe(true);
+ expect(
+ React.isValidElement(
+
+
+
+
+ ,
+ ),
+ ).toBe(true);
});
- let wrapper;
- beforeEach(() => {
- wrapper = shallow();
+
+ beforeEach(async () => {
+ // You need this await function in order to change state in the app. In fact you need it everytime you re-render.
+ await act(async () => {
+ render(
+
+
+
+
+ ,
+ );
+ });
});
- it('should have three Select', () => {
- expect(wrapper.findWhere(x => x.type() === Select)).toHaveLength(3);
+ it('should have three Selects', () => {
+ expect(screen.getByText(/28 days ago/i)).toBeInTheDocument();
+ expect(screen.getByText(/now/i)).toBeInTheDocument();
+ expect(screen.getByText(/success/i)).toBeInTheDocument();
});
it('updates fromTime on user selects from time', () => {
- wrapper.find('[name="select-from"]').simulate('change', { value: 0 });
- expect(wrapper.state().from).toBe(0);
+ const role = screen.getByText(/28 days ago/i);
+ fireEvent.keyDown(role, { key: 'ArrowDown', keyCode: 40 });
+ userEvent.click(screen.getByText(/1 hour ago/i));
+ expect(screen.getByText(/1 hour ago/i)).toBeInTheDocument();
});
- it('updates toTime on user selects to time', () => {
- wrapper.find('[name="select-to"]').simulate('change', { value: 0 });
- expect(wrapper.state().to).toBe(0);
+ it('updates toTime on user selects on time', () => {
+ const role = screen.getByText(/now/i);
+ fireEvent.keyDown(role, { key: 'ArrowDown', keyCode: 40 });
+ userEvent.click(screen.getByText(/1 hour ago/i));
+ expect(screen.getByText(/1 hour ago/i)).toBeInTheDocument();
});
it('updates status on user selects status', () => {
- wrapper
- .find('[name="select-status"]')
- .simulate('change', { value: 'success' });
- expect(wrapper.state().status).toBe('success');
+ const role = screen.getByText(/success/i);
+ fireEvent.keyDown(role, { key: 'ArrowDown', keyCode: 40 });
+ userEvent.click(screen.getByText(/failed/i));
+ expect(screen.getByText(/failed/i)).toBeInTheDocument();
});
it('should have one input for searchText', () => {
- expect(wrapper.find('input')).toExist();
+ expect(
+ screen.getByPlaceholderText(/Query search string/i),
+ ).toBeInTheDocument();
});
it('updates search text on user inputs search text', () => {
- wrapper.find('input').simulate('change', { target: { value: 'text' } });
- expect(wrapper.state().searchText).toBe('text');
+ const search = screen.getByPlaceholderText(/Query search string/i);
+ userEvent.type(search, 'text');
+ expect(search.value).toBe('text');
});
- it('refreshes queries when enter (only) is pressed on the input', () => {
- const { callCount } = search;
- wrapper.find('input').simulate('keyDown', { keyCode: 'a'.charCodeAt(0) });
- expect(search.callCount).toBe(callCount);
- wrapper.find('input').simulate('keyDown', { keyCode: '\r'.charCodeAt(0) });
- expect(search.callCount).toBe(callCount + 1);
+ it('should have one Button', () => {
+ const button = screen.getAllByRole('button');
+ expect(button.length).toEqual(1);
});
- it('should have one Button', () => {
- expect(wrapper.find(Button)).toExist();
+ it('should call API when search button is pressed', async () => {
+ fetchMock.resetHistory();
+ const button = screen.getByRole('button');
+ await act(async () => {
+ userEvent.click(button);
+ });
+ expect(fetchMock.calls(SEARCH_ENDPOINT)).toHaveLength(1);
});
- it('refreshes queries when clicked', () => {
- const { callCount } = search;
- wrapper.find(Button).simulate('click');
- expect(search.callCount).toBe(callCount + 1);
+ it('should call API when (only)enter key is pressed', async () => {
+ fetchMock.resetHistory();
+ const search = screen.getByPlaceholderText(/Query search string/i);
+ await act(async () => {
+ userEvent.type(search, 'a');
+ });
+ expect(fetchMock.calls(SEARCH_ENDPOINT)).toHaveLength(0);
+ await act(async () => {
+ userEvent.type(search, '{enter}');
+ });
+ expect(fetchMock.calls(SEARCH_ENDPOINT)).toHaveLength(1);
});
});
diff --git a/superset-frontend/src/SqlLab/components/QuerySearch.jsx b/superset-frontend/src/SqlLab/components/QuerySearch.jsx
deleted file mode 100644
index f5e188780ae6..000000000000
--- a/superset-frontend/src/SqlLab/components/QuerySearch.jsx
+++ /dev/null
@@ -1,330 +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 PropTypes from 'prop-types';
-import Button from 'src/components/Button';
-import Select from 'src/components/Select';
-import { styled, t, SupersetClient } from '@superset-ui/core';
-
-import Loading from '../../components/Loading';
-import QueryTable from './QueryTable';
-import {
- now,
- epochTimeXHoursAgo,
- epochTimeXDaysAgo,
- epochTimeXYearsAgo,
-} from '../../modules/dates';
-import { STATUS_OPTIONS, TIME_OPTIONS } from '../constants';
-import AsyncSelect from '../../components/AsyncSelect';
-
-const propTypes = {
- actions: PropTypes.object.isRequired,
- height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
- displayLimit: PropTypes.number.isRequired,
-};
-
-const TableWrapper = styled.div`
- display: flex;
- flex-direction: column;
- flex: 1;
- height: 100%;
-`;
-
-const TableStyles = styled.div`
- table {
- background-color: ${({ theme }) => theme.colors.grayscale.light4};
- }
-
- .table > thead > tr > th {
- border-bottom: ${({ theme }) => theme.gridUnit / 2}px solid
- ${({ theme }) => theme.colors.grayscale.light2};
- background: ${({ theme }) => theme.colors.grayscale.light4};
- }
-`;
-
-const StyledTableStylesContainer = styled.div`
- overflow: auto;
-`;
-
-class QuerySearch extends React.PureComponent {
- constructor(props) {
- super(props);
- this.state = {
- databaseId: null,
- userId: null,
- searchText: null,
- from: '28 days ago',
- to: 'now',
- status: 'success',
- queriesArray: [],
- queriesLoading: true,
- };
- this.userMutator = this.userMutator.bind(this);
- this.changeUser = this.changeUser.bind(this);
- this.dbMutator = this.dbMutator.bind(this);
- this.onChange = this.onChange.bind(this);
- this.changeSearch = this.changeSearch.bind(this);
- this.onKeyDown = this.onKeyDown.bind(this);
- this.changeFrom = this.changeFrom.bind(this);
- this.changeTo = this.changeTo.bind(this);
- this.changeStatus = this.changeStatus.bind(this);
- this.refreshQueries = this.refreshQueries.bind(this);
- this.onUserClicked = this.onUserClicked.bind(this);
- this.onDbClicked = this.onDbClicked.bind(this);
- }
-
- componentDidMount() {
- this.refreshQueries();
- }
-
- onUserClicked(userId) {
- this.setState({ userId }, () => {
- this.refreshQueries();
- });
- }
-
- onDbClicked(dbId) {
- this.setState({ databaseId: dbId }, () => {
- this.refreshQueries();
- });
- }
-
- onChange(db) {
- const val = db ? db.value : null;
- this.setState({ databaseId: val });
- }
-
- onKeyDown(event) {
- if (event.keyCode === 13) {
- this.refreshQueries();
- }
- }
-
- getTimeFromSelection(selection) {
- switch (selection) {
- case 'now':
- return now();
- case '1 hour ago':
- return epochTimeXHoursAgo(1);
- case '1 day ago':
- return epochTimeXDaysAgo(1);
- case '7 days ago':
- return epochTimeXDaysAgo(7);
- case '28 days ago':
- return epochTimeXDaysAgo(28);
- case '90 days ago':
- return epochTimeXDaysAgo(90);
- case '1 year ago':
- return epochTimeXYearsAgo(1);
- default:
- return null;
- }
- }
-
- changeFrom(user) {
- const val = user ? user.value : null;
- this.setState({ from: val });
- }
-
- changeTo(status) {
- const val = status ? status.value : null;
- this.setState({ to: val });
- }
-
- changeUser(user) {
- const val = user ? user.value : null;
- this.setState({ userId: val });
- }
-
- insertParams(baseUrl, params) {
- const validParams = params.filter(function (p) {
- return p !== '';
- });
- return `${baseUrl}?${validParams.join('&')}`;
- }
-
- changeStatus(status) {
- const val = status ? status.value : null;
- this.setState({ status: val });
- }
-
- changeSearch(event) {
- this.setState({ searchText: event.target.value });
- }
-
- userLabel(user) {
- if (user.first_name && user.last_name) {
- return `${user.first_name} ${user.last_name}`;
- }
- return user.username;
- }
-
- userMutator(data) {
- return data.result.map(({ value, text }) => ({
- label: text,
- value,
- }));
- }
-
- dbMutator(data) {
- const options = data.result.map(db => ({
- value: db.id,
- label: db.database_name,
- }));
- this.props.actions.setDatabases(data.result);
- if (data.result.length === 0) {
- this.props.actions.addDangerToast(
- t("It seems you don't have access to any database"),
- );
- }
- return options;
- }
-
- refreshQueries() {
- this.setState({ queriesLoading: true });
- const params = [
- this.state.userId ? `user_id=${this.state.userId}` : '',
- this.state.databaseId ? `database_id=${this.state.databaseId}` : '',
- this.state.searchText ? `search_text=${this.state.searchText}` : '',
- this.state.status ? `status=${this.state.status}` : '',
- this.state.from
- ? `from=${this.getTimeFromSelection(this.state.from)}`
- : '',
- this.state.to ? `to=${this.getTimeFromSelection(this.state.to)}` : '',
- ];
-
- SupersetClient.get({
- endpoint: this.insertParams('/superset/search_queries', params),
- })
- .then(({ json }) => {
- this.setState({ queriesArray: json, queriesLoading: false });
- })
- .catch(() => {
- this.props.actions.addDangerToast(
- t('An error occurred when refreshing queries'),
- );
- });
- }
-
- render() {
- return (
-
-
-
- {this.state.queriesLoading ? (
-
- ) : (
-
-
-
- )}
-
-
- );
- }
-}
-QuerySearch.propTypes = propTypes;
-export default QuerySearch;
diff --git a/superset-frontend/src/SqlLab/components/QuerySearch.tsx b/superset-frontend/src/SqlLab/components/QuerySearch.tsx
new file mode 100644
index 000000000000..978238bffafd
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/QuerySearch.tsx
@@ -0,0 +1,288 @@
+/**
+ * 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, { useState, useEffect } from 'react';
+import Button from 'src/components/Button';
+import Select from 'src/components/Select';
+import { styled, t, SupersetClient } from '@superset-ui/core';
+import { debounce } from 'lodash';
+import Loading from '../../components/Loading';
+import QueryTable from './QueryTable';
+import {
+ now,
+ epochTimeXHoursAgo,
+ epochTimeXDaysAgo,
+ epochTimeXYearsAgo,
+} from '../../modules/dates';
+import { STATUS_OPTIONS, TIME_OPTIONS } from '../constants';
+import AsyncSelect from '../../components/AsyncSelect';
+import { Query } from '../types';
+
+interface QuerySearchProps {
+ actions: {
+ addDangerToast: (msg: string) => void;
+ setDatabases: (data: Record) => Record;
+ };
+ displayLimit: number;
+}
+
+interface UserMutatorProps {
+ value: number;
+ text: string;
+}
+
+interface DbMutatorProps {
+ id: number;
+ database_name: string;
+}
+
+const TableWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ height: 100%;
+`;
+
+const TableStyles = styled.div`
+ table {
+ background-color: ${({ theme }) => theme.colors.grayscale.light4};
+ }
+
+ .table > thead > tr > th {
+ border-bottom: ${({ theme }) => theme.gridUnit / 2}px solid
+ ${({ theme }) => theme.colors.grayscale.light2};
+ background: ${({ theme }) => theme.colors.grayscale.light4};
+ }
+`;
+
+const StyledTableStylesContainer = styled.div`
+ overflow: auto;
+`;
+function QuerySearch({ actions, displayLimit }: QuerySearchProps) {
+ const [databaseId, setDatabaseId] = useState('');
+ const [userId, setUserId] = useState('');
+ const [searchText, setSearchText] = useState('');
+ const [from, setFrom] = useState('28 days ago');
+ const [to, setTo] = useState('now');
+ const [status, setStatus] = useState('success');
+ const [queriesArray, setQueriesArray] = useState([]);
+ const [queriesLoading, setQueriesLoading] = useState(true);
+
+ const getTimeFromSelection = (selection: string) => {
+ switch (selection) {
+ case 'now':
+ return now();
+ case '1 hour ago':
+ return epochTimeXHoursAgo(1);
+ case '1 day ago':
+ return epochTimeXDaysAgo(1);
+ case '7 days ago':
+ return epochTimeXDaysAgo(7);
+ case '28 days ago':
+ return epochTimeXDaysAgo(28);
+ case '90 days ago':
+ return epochTimeXDaysAgo(90);
+ case '1 year ago':
+ return epochTimeXYearsAgo(1);
+ default:
+ return null;
+ }
+ };
+
+ const insertParams = (baseUrl: string, params: string[]) => {
+ const validParams = params.filter(function (p) {
+ return p !== '';
+ });
+ return `${baseUrl}?${validParams.join('&')}`;
+ };
+
+ const refreshQueries = async () => {
+ setQueriesLoading(true);
+ const params = [
+ userId && `user_id=${userId}`,
+ databaseId && `database_id=${databaseId}`,
+ searchText && `search_text=${searchText}`,
+ status && `status=${status}`,
+ from && `from=${getTimeFromSelection(from)}`,
+ to && `to=${getTimeFromSelection(to)}`,
+ ];
+
+ try {
+ const response = await SupersetClient.get({
+ endpoint: insertParams('/superset/search_queries', params),
+ });
+ const queries = Object.values(response.json);
+ setQueriesArray(queries);
+ } catch (err) {
+ actions.addDangerToast(t('An error occurred when refreshing queries'));
+ } finally {
+ setQueriesLoading(false);
+ }
+ };
+ useEffect(() => {
+ refreshQueries();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const onUserClicked = (userId: string) => {
+ setUserId(userId);
+ refreshQueries();
+ };
+
+ const onDbClicked = (dbId: string) => {
+ setDatabaseId(dbId);
+ refreshQueries();
+ };
+
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ if (event.keyCode === 13) {
+ refreshQueries();
+ }
+ };
+
+ const onChange = (e: React.ChangeEvent) => {
+ e.persist();
+ const handleChange = debounce(e => {
+ setSearchText(e.target.value);
+ }, 200);
+ handleChange(e);
+ };
+
+ const userMutator = ({ result }: { result: UserMutatorProps[] }) =>
+ result.map(({ value, text }: UserMutatorProps) => ({
+ label: text,
+ value,
+ }));
+
+ const dbMutator = ({ result }: { result: DbMutatorProps[] }) => {
+ const options = result.map(({ id, database_name }: DbMutatorProps) => ({
+ value: id,
+ label: database_name,
+ }));
+ actions.setDatabases(result);
+ if (result.length === 0) {
+ actions.addDangerToast(
+ t("It seems you don't have access to any database"),
+ );
+ }
+ return options;
+ };
+
+ return (
+
+
+
+ {queriesLoading ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+}
+export default QuerySearch;