You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by kg...@apache.org on 2022/09/06 14:55:23 UTC

[superset] branch master updated: chore: refactor ResultSet to functional component (#21186)

This is an automated email from the ASF dual-hosted git repository.

kgabryje pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new f603295678 chore: refactor ResultSet to functional component (#21186)
f603295678 is described below

commit f6032956780380bdac1e3ae950687ae53eabf2e1
Author: EugeneTorap <ev...@gmail.com>
AuthorDate: Tue Sep 6 17:55:07 2022 +0300

    chore: refactor ResultSet to functional component (#21186)
---
 .../components/ExploreCtasResultsButton/index.tsx  |  25 +-
 .../components/ExploreResultsButton/index.tsx      |   1 +
 .../src/SqlLab/components/QueryTable/index.tsx     |   1 -
 .../SqlLab/components/ResultSet/ResultSet.test.jsx | 219 --------
 .../SqlLab/components/ResultSet/ResultSet.test.tsx | 216 ++++++++
 .../src/SqlLab/components/ResultSet/index.tsx      | 573 ++++++++++-----------
 .../src/SqlLab/components/SouthPane/index.tsx      |   3 -
 .../src/components/FilterableTable/index.tsx       |   3 +-
 .../src/components/ProgressBar/index.tsx           |   2 +-
 9 files changed, 501 insertions(+), 542 deletions(-)

diff --git a/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/index.tsx b/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/index.tsx
index fbcdc15bc5..ac9e8b2fb4 100644
--- a/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/index.tsx
+++ b/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/index.tsx
@@ -17,19 +17,19 @@
  * under the License.
  */
 import React from 'react';
-import { useSelector } from 'react-redux';
-import { t } from '@superset-ui/core';
+import { useSelector, useDispatch } from 'react-redux';
+import { t, JsonObject } from '@superset-ui/core';
+import {
+  createCtasDatasource,
+  addInfoToast,
+  addDangerToast,
+} from 'src/SqlLab/actions/sqlLab';
 import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls';
 import Button from 'src/components/Button';
 import { exploreChart } from 'src/explore/exploreUtils';
 import { SqlLabRootState } from 'src/SqlLab/types';
 
 interface ExploreCtasResultsButtonProps {
-  actions: {
-    createCtasDatasource: Function;
-    addInfoToast: Function;
-    addDangerToast: Function;
-  };
   table: string;
   schema?: string | null;
   dbId: number;
@@ -37,16 +37,15 @@ interface ExploreCtasResultsButtonProps {
 }
 
 const ExploreCtasResultsButton = ({
-  actions,
   table,
   schema,
   dbId,
   templateParams,
 }: ExploreCtasResultsButtonProps) => {
-  const { createCtasDatasource, addInfoToast, addDangerToast } = actions;
   const errorMessage = useSelector(
     (state: SqlLabRootState) => state.sqlLab.errorMessage,
   );
+  const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
 
   const buildVizOptions = {
     datasourceName: table,
@@ -56,7 +55,7 @@ const ExploreCtasResultsButton = ({
   };
 
   const visualize = () => {
-    createCtasDatasource(buildVizOptions)
+    dispatch(createCtasDatasource(buildVizOptions))
       .then((data: { table_id: number }) => {
         const formData = {
           datasource: `${data.table_id}__table`,
@@ -67,12 +66,14 @@ const ExploreCtasResultsButton = ({
           all_columns: [],
           row_limit: 1000,
         };
-        addInfoToast(t('Creating a data source and creating a new tab'));
+        dispatch(
+          addInfoToast(t('Creating a data source and creating a new tab')),
+        );
         // open new window for data visualization
         exploreChart(formData);
       })
       .catch(() => {
-        addDangerToast(errorMessage || t('An error occurred'));
+        dispatch(addDangerToast(errorMessage || t('An error occurred')));
       });
   };
 
diff --git a/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx b/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx
index 24d5e8686f..4ab7777736 100644
--- a/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx
+++ b/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx
@@ -39,6 +39,7 @@ const ExploreResultsButton = ({
       onClick={onClick}
       disabled={!allowsSubquery}
       tooltip={t('Explore the result set in the data exploration view')}
+      data-test="explore-results-button"
     >
       <InfoTooltipWithTrigger
         icon="line-chart"
diff --git a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx
index 54edb7f97e..d7ef5ed51c 100644
--- a/superset-frontend/src/SqlLab/components/QueryTable/index.tsx
+++ b/superset-frontend/src/SqlLab/components/QueryTable/index.tsx
@@ -245,7 +245,6 @@ const QueryTable = ({
                   showSql
                   user={user}
                   query={query}
-                  actions={actions}
                   height={400}
                   displayLimit={displayLimit}
                   defaultQueryLimit={1000}
diff --git a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.jsx b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.jsx
deleted file mode 100644
index c04e236133..0000000000
--- a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.jsx
+++ /dev/null
@@ -1,219 +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 { shallow } from 'enzyme';
-import { styledMount } from 'spec/helpers/theming';
-import { render, screen } from 'spec/helpers/testing-library';
-import { Provider } from 'react-redux';
-import sinon from 'sinon';
-import Alert from 'src/components/Alert';
-import ProgressBar from 'src/components/ProgressBar';
-import Loading from 'src/components/Loading';
-import configureStore from 'redux-mock-store';
-import thunk from 'redux-thunk';
-import fetchMock from 'fetch-mock';
-import FilterableTable from 'src/components/FilterableTable';
-import ExploreResultsButton from 'src/SqlLab/components/ExploreResultsButton';
-import ResultSet from 'src/SqlLab/components/ResultSet';
-import ErrorMessageWithStackTrace from 'src/components/ErrorMessage/ErrorMessageWithStackTrace';
-import {
-  cachedQuery,
-  failedQueryWithErrorMessage,
-  failedQueryWithErrors,
-  queries,
-  runningQuery,
-  stoppedQuery,
-  initialState,
-  user,
-  queryWithNoQueryLimit,
-} from 'src/SqlLab/fixtures';
-
-const mockStore = configureStore([thunk]);
-const store = mockStore(initialState);
-const clearQuerySpy = sinon.spy();
-const fetchQuerySpy = sinon.spy();
-const reRunQuerySpy = sinon.spy();
-const mockedProps = {
-  actions: {
-    clearQueryResults: clearQuerySpy,
-    fetchQueryResults: fetchQuerySpy,
-    reRunQuery: reRunQuerySpy,
-  },
-  cache: true,
-  query: queries[0],
-  height: 140,
-  database: { allows_virtual_table_explore: true },
-  user,
-  defaultQueryLimit: 1000,
-};
-const stoppedQueryProps = { ...mockedProps, query: stoppedQuery };
-const runningQueryProps = { ...mockedProps, query: runningQuery };
-const fetchingQueryProps = {
-  ...mockedProps,
-  query: {
-    dbId: 1,
-    cached: false,
-    ctas: false,
-    id: 'ryhHUZCGb',
-    progress: 100,
-    state: 'fetching',
-    startDttm: Date.now() - 500,
-  },
-};
-const cachedQueryProps = { ...mockedProps, query: cachedQuery };
-const failedQueryWithErrorMessageProps = {
-  ...mockedProps,
-  query: failedQueryWithErrorMessage,
-};
-const failedQueryWithErrorsProps = {
-  ...mockedProps,
-  query: failedQueryWithErrors,
-};
-const newProps = {
-  query: {
-    cached: false,
-    resultsKey: 'new key',
-    results: {
-      data: [{ a: 1 }],
-    },
-  },
-};
-fetchMock.get('glob:*/api/v1/dataset?*', { result: [] });
-
-test('is valid', () => {
-  expect(React.isValidElement(<ResultSet {...mockedProps} />)).toBe(true);
-});
-
-test('renders a Table', () => {
-  const wrapper = shallow(<ResultSet {...mockedProps} />);
-  expect(wrapper.find(FilterableTable)).toExist();
-});
-
-describe('componentDidMount', () => {
-  const propsWithError = {
-    ...mockedProps,
-    query: { ...queries[0], errorMessage: 'Your session timed out' },
-  };
-  let spy;
-  beforeEach(() => {
-    reRunQuerySpy.resetHistory();
-    spy = sinon.spy(ResultSet.prototype, 'componentDidMount');
-  });
-  afterEach(() => {
-    spy.restore();
-  });
-  it('should call reRunQuery if timed out', () => {
-    shallow(<ResultSet {...propsWithError} />);
-    expect(reRunQuerySpy.callCount).toBe(1);
-  });
-
-  it('should not call reRunQuery if no error', () => {
-    shallow(<ResultSet {...mockedProps} />);
-    expect(reRunQuerySpy.callCount).toBe(0);
-  });
-});
-
-describe('UNSAFE_componentWillReceiveProps', () => {
-  const wrapper = shallow(<ResultSet {...mockedProps} />);
-  let spy;
-  beforeEach(() => {
-    clearQuerySpy.resetHistory();
-    fetchQuerySpy.resetHistory();
-    spy = sinon.spy(ResultSet.prototype, 'UNSAFE_componentWillReceiveProps');
-  });
-  afterEach(() => {
-    spy.restore();
-  });
-  it('should update cached data', () => {
-    wrapper.setProps(newProps);
-
-    expect(wrapper.state().data).toEqual(newProps.query.results.data);
-    expect(clearQuerySpy.callCount).toBe(1);
-    expect(clearQuerySpy.getCall(0).args[0]).toEqual(newProps.query);
-    expect(fetchQuerySpy.callCount).toBe(1);
-    expect(fetchQuerySpy.getCall(0).args[0]).toEqual(newProps.query);
-  });
-});
-
-test('should render success query', () => {
-  const wrapper = shallow(<ResultSet {...mockedProps} />);
-  const filterableTable = wrapper.find(FilterableTable);
-  expect(filterableTable.props().data).toBe(mockedProps.query.results.data);
-  expect(wrapper.find(ExploreResultsButton)).toExist();
-});
-test('should render empty results', () => {
-  const props = {
-    ...mockedProps,
-    query: { ...mockedProps.query, results: { data: [] } },
-  };
-  const wrapper = styledMount(
-    <Provider store={store}>
-      <ResultSet {...props} />
-    </Provider>,
-  );
-  expect(wrapper.find(FilterableTable)).not.toExist();
-  expect(wrapper.find(Alert)).toExist();
-  expect(wrapper.find(Alert).render().text()).toBe(
-    'The query returned no data',
-  );
-});
-
-test('should render cached query', () => {
-  const wrapper = shallow(<ResultSet {...cachedQueryProps} />);
-  const cachedData = [{ col1: 'a', col2: 'b' }];
-  wrapper.setState({ data: cachedData });
-  const filterableTable = wrapper.find(FilterableTable);
-  expect(filterableTable.props().data).toBe(cachedData);
-});
-
-test('should render stopped query', () => {
-  const wrapper = shallow(<ResultSet {...stoppedQueryProps} />);
-  expect(wrapper.find(Alert)).toExist();
-});
-
-test('should render running/pending/fetching query', () => {
-  const wrapper = shallow(<ResultSet {...runningQueryProps} />);
-  expect(wrapper.find(ProgressBar)).toExist();
-});
-
-test('should render fetching w/ 100 progress query', () => {
-  const wrapper = shallow(<ResultSet {...fetchingQueryProps} />);
-  expect(wrapper.find(Loading)).toExist();
-});
-
-test('should render a failed query with an error message', () => {
-  const wrapper = shallow(<ResultSet {...failedQueryWithErrorMessageProps} />);
-  expect(wrapper.find(ErrorMessageWithStackTrace)).toExist();
-});
-
-test('should render a failed query with an errors object', () => {
-  const wrapper = shallow(<ResultSet {...failedQueryWithErrorsProps} />);
-  expect(wrapper.find(ErrorMessageWithStackTrace)).toExist();
-});
-
-test('renders if there is no limit in query.results but has queryLimit', () => {
-  render(<ResultSet {...mockedProps} />, { useRedux: true });
-  expect(screen.getByRole('grid')).toBeInTheDocument();
-});
-
-test('renders if there is a limit in query.results but not queryLimit', () => {
-  const props = { ...mockedProps, query: queryWithNoQueryLimit };
-  render(<ResultSet {...props} />, { useRedux: true });
-  expect(screen.getByRole('grid')).toBeInTheDocument();
-});
diff --git a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
new file mode 100644
index 0000000000..2818ed279c
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
@@ -0,0 +1,216 @@
+/**
+ * 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 { render, screen, waitFor } from 'spec/helpers/testing-library';
+import configureStore from 'redux-mock-store';
+import { Store } from 'redux';
+import thunk from 'redux-thunk';
+import fetchMock from 'fetch-mock';
+import ResultSet from 'src/SqlLab/components/ResultSet';
+import {
+  cachedQuery,
+  failedQueryWithErrorMessage,
+  failedQueryWithErrors,
+  queries,
+  runningQuery,
+  stoppedQuery,
+  initialState,
+  user,
+  queryWithNoQueryLimit,
+} from 'src/SqlLab/fixtures';
+
+const mockedProps = {
+  cache: true,
+  query: queries[0],
+  height: 140,
+  database: { allows_virtual_table_explore: true },
+  user,
+  defaultQueryLimit: 1000,
+};
+const stoppedQueryProps = { ...mockedProps, query: stoppedQuery };
+const runningQueryProps = { ...mockedProps, query: runningQuery };
+const fetchingQueryProps = {
+  ...mockedProps,
+  query: {
+    dbId: 1,
+    cached: false,
+    ctas: false,
+    id: 'ryhHUZCGb',
+    progress: 100,
+    state: 'fetching',
+    startDttm: Date.now() - 500,
+  },
+};
+const cachedQueryProps = { ...mockedProps, query: cachedQuery };
+const failedQueryWithErrorMessageProps = {
+  ...mockedProps,
+  query: failedQueryWithErrorMessage,
+};
+const failedQueryWithErrorsProps = {
+  ...mockedProps,
+  query: failedQueryWithErrors,
+};
+const newProps = {
+  query: {
+    cached: false,
+    resultsKey: 'new key',
+    results: {
+      data: [{ a: 1 }],
+    },
+  },
+};
+fetchMock.get('glob:*/api/v1/dataset?*', { result: [] });
+
+const middlewares = [thunk];
+const mockStore = configureStore(middlewares);
+const setup = (props?: any, store?: Store) =>
+  render(<ResultSet {...props} />, {
+    useRedux: true,
+    ...(store && { store }),
+  });
+
+describe('ResultSet', () => {
+  it('renders a Table', async () => {
+    const { getByTestId } = setup(mockedProps, mockStore(initialState));
+    const table = getByTestId('table-container');
+    expect(table).toBeInTheDocument();
+  });
+
+  it('should render success query', async () => {
+    const { queryAllByText, getByTestId } = setup(
+      mockedProps,
+      mockStore(initialState),
+    );
+
+    const table = getByTestId('table-container');
+    expect(table).toBeInTheDocument();
+
+    const firstColumn = queryAllByText(
+      mockedProps.query.results?.columns[0].name ?? '',
+    )[0];
+    const secondColumn = queryAllByText(
+      mockedProps.query.results?.columns[1].name ?? '',
+    )[0];
+    expect(firstColumn).toBeInTheDocument();
+    expect(secondColumn).toBeInTheDocument();
+
+    const exploreButton = getByTestId('explore-results-button');
+    expect(exploreButton).toBeInTheDocument();
+  });
+
+  it('should render empty results', async () => {
+    const props = {
+      ...mockedProps,
+      query: { ...mockedProps.query, results: { data: [] } },
+    };
+    await waitFor(() => {
+      setup(props, mockStore(initialState));
+    });
+
+    const alert = screen.getByRole('alert');
+    expect(alert).toBeInTheDocument();
+    expect(alert).toHaveTextContent('The query returned no data');
+  });
+
+  it('should call reRunQuery if timed out', async () => {
+    const store = mockStore(initialState);
+    const propsWithError = {
+      ...mockedProps,
+      query: { ...queries[0], errorMessage: 'Your session timed out' },
+    };
+
+    setup(propsWithError, store);
+    expect(store.getActions()).toHaveLength(1);
+    expect(store.getActions()[0].query.errorMessage).toEqual(
+      'Your session timed out',
+    );
+    expect(store.getActions()[0].type).toEqual('START_QUERY');
+  });
+
+  it('should not call reRunQuery if no error', async () => {
+    const store = mockStore(initialState);
+    setup(mockedProps, store);
+    expect(store.getActions()).toEqual([]);
+  });
+
+  it('should render cached query', async () => {
+    const store = mockStore(initialState);
+    const { rerender } = setup(cachedQueryProps, store);
+
+    // @ts-ignore
+    rerender(<ResultSet {...newProps} />);
+    expect(store.getActions()).toHaveLength(1);
+    expect(store.getActions()[0].query.results).toEqual(
+      cachedQueryProps.query.results,
+    );
+    expect(store.getActions()[0].type).toEqual('CLEAR_QUERY_RESULTS');
+  });
+
+  it('should render stopped query', async () => {
+    await waitFor(() => {
+      setup(stoppedQueryProps, mockStore(initialState));
+    });
+
+    const alert = screen.getByRole('alert');
+    expect(alert).toBeInTheDocument();
+  });
+
+  it('should render running/pending/fetching query', async () => {
+    const { getByTestId } = setup(runningQueryProps, mockStore(initialState));
+    const progressBar = getByTestId('progress-bar');
+    expect(progressBar).toBeInTheDocument();
+  });
+
+  it('should render fetching w/ 100 progress query', async () => {
+    const { getByRole, getByText } = setup(
+      fetchingQueryProps,
+      mockStore(initialState),
+    );
+    const loading = getByRole('status');
+    expect(loading).toBeInTheDocument();
+    expect(getByText('fetching')).toBeInTheDocument();
+  });
+
+  it('should render a failed query with an error message', async () => {
+    await waitFor(() => {
+      setup(failedQueryWithErrorMessageProps, mockStore(initialState));
+    });
+
+    expect(screen.getByText('Database error')).toBeInTheDocument();
+    expect(screen.getByText('Something went wrong')).toBeInTheDocument();
+  });
+
+  it('should render a failed query with an errors object', async () => {
+    await waitFor(() => {
+      setup(failedQueryWithErrorsProps, mockStore(initialState));
+    });
+    expect(screen.getByText('Database error')).toBeInTheDocument();
+  });
+
+  it('renders if there is no limit in query.results but has queryLimit', async () => {
+    const { getByRole } = setup(mockedProps, mockStore(initialState));
+    expect(getByRole('grid')).toBeInTheDocument();
+  });
+
+  it('renders if there is a limit in query.results but not queryLimit', async () => {
+    const props = { ...mockedProps, query: queryWithNoQueryLimit };
+    const { getByRole } = setup(props, mockStore(initialState));
+    expect(getByRole('grid')).toBeInTheDocument();
+  });
+});
diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
index 27913d7fc4..78387f6dc4 100644
--- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
+++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
@@ -16,12 +16,14 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import React from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
+import { useDispatch } from 'react-redux';
 import ButtonGroup from 'src/components/ButtonGroup';
 import Alert from 'src/components/Alert';
 import Button from 'src/components/Button';
 import shortid from 'shortid';
 import { styled, t, QueryResponse } from '@superset-ui/core';
+import { usePrevious } from 'src/hooks/usePrevious';
 import ErrorMessageWithStackTrace from 'src/components/ErrorMessage/ErrorMessageWithStackTrace';
 import {
   ISaveableDatasource,
@@ -40,7 +42,14 @@ import FilterableTable, {
 import CopyToClipboard from 'src/components/CopyToClipboard';
 import { addDangerToast } from 'src/components/MessageToasts/actions';
 import { prepareCopyToClipboardTabularData } from 'src/utils/common';
-import { CtasEnum } from 'src/SqlLab/actions/sqlLab';
+import {
+  CtasEnum,
+  clearQueryResults,
+  addQueryEditor,
+  fetchQueryResults,
+  reFetchQueryResults,
+  reRunQuery,
+} from 'src/SqlLab/actions/sqlLab';
 import { URL_PARAMS } from 'src/constants';
 import ExploreCtasResultsButton from '../ExploreCtasResultsButton';
 import ExploreResultsButton from '../ExploreResultsButton';
@@ -54,9 +63,7 @@ enum LIMITING_FACTOR {
   NOT_LIMITED = 'NOT_LIMITED',
 }
 
-interface ResultSetProps {
-  showControls?: boolean;
-  actions: Record<string, any>;
+export interface ResultSetProps {
   cache?: boolean;
   csv?: boolean;
   database?: Record<string, any>;
@@ -70,17 +77,9 @@ interface ResultSetProps {
   defaultQueryLimit: number;
 }
 
-interface ResultSetState {
-  searchText: string;
-  showExploreResultsButton: boolean;
-  data: Record<string, any>[];
-  showSaveDatasetModal: boolean;
-  alertIsOpen: boolean;
-}
-
 const ResultlessStyles = styled.div`
   position: relative;
-  min-height: 100px;
+  min-height: ${({ theme }) => theme.gridUnit * 25}px;
   [role='alert'] {
     margin-top: ${({ theme }) => theme.gridUnit * 2}px;
   }
@@ -100,8 +99,8 @@ const MonospaceDiv = styled.div`
 `;
 
 const ReturnedRows = styled.div`
-  font-size: 13px;
-  line-height: 24px;
+  font-size: ${({ theme }) => theme.typography.sizes.s}px;
+  line-height: ${({ theme }) => theme.gridUnit * 6}px;
 `;
 
 const ResultSetControls = styled.div`
@@ -121,115 +120,84 @@ const LimitMessage = styled.span`
   margin-left: ${({ theme }) => theme.gridUnit * 2}px;
 `;
 
-export default class ResultSet extends React.PureComponent<
-  ResultSetProps,
-  ResultSetState
-> {
-  static defaultProps = {
-    cache: false,
-    csv: true,
-    database: {},
-    search: true,
-    showSql: false,
-    visualize: true,
-  };
-
-  constructor(props: ResultSetProps) {
-    super(props);
-    this.state = {
-      searchText: '',
-      showExploreResultsButton: false,
-      data: [],
-      showSaveDatasetModal: false,
-      alertIsOpen: false,
-    };
-    this.changeSearch = this.changeSearch.bind(this);
-    this.fetchResults = this.fetchResults.bind(this);
-    this.popSelectStar = this.popSelectStar.bind(this);
-    this.reFetchQueryResults = this.reFetchQueryResults.bind(this);
-    this.toggleExploreResultsButton =
-      this.toggleExploreResultsButton.bind(this);
-  }
+const ResultSet = ({
+  cache = false,
+  csv = true,
+  database = {},
+  displayLimit,
+  height,
+  query,
+  search = true,
+  showSql = false,
+  visualize = true,
+  user,
+  defaultQueryLimit,
+}: ResultSetProps) => {
+  const [searchText, setSearchText] = useState('');
+  const [cachedData, setCachedData] = useState<Record<string, unknown>[]>([]);
+  const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
+  const [alertIsOpen, setAlertIsOpen] = useState(false);
+
+  const dispatch = useDispatch();
+
+  const reRunQueryIfSessionTimeoutErrorOnMount = useCallback(() => {
+    if (
+      query.errorMessage &&
+      query.errorMessage.indexOf('session timed out') > 0
+    ) {
+      dispatch(reRunQuery(query));
+    }
+  }, []);
 
-  async componentDidMount() {
+  useEffect(() => {
     // only do this the first time the component is rendered/mounted
-    this.reRunQueryIfSessionTimeoutErrorOnMount();
-  }
+    reRunQueryIfSessionTimeoutErrorOnMount();
+  }, [reRunQueryIfSessionTimeoutErrorOnMount]);
 
-  UNSAFE_componentWillReceiveProps(nextProps: ResultSetProps) {
-    // when new results comes in, save them locally and clear in store
-    if (
-      this.props.cache &&
-      !nextProps.query.cached &&
-      nextProps.query.results &&
-      nextProps.query.results.data &&
-      nextProps.query.results.data.length > 0
-    ) {
-      this.setState({ data: nextProps.query.results.data }, () =>
-        this.clearQueryResults(nextProps.query),
-      );
+  const fetchResults = (query: QueryResponse) => {
+    dispatch(fetchQueryResults(query, displayLimit));
+  };
+
+  const prevQuery = usePrevious(query);
+  useEffect(() => {
+    if (cache && query.cached && query?.results?.data?.length > 0) {
+      setCachedData(query.results.data);
+      dispatch(clearQueryResults(query));
     }
     if (
-      nextProps.query.resultsKey &&
-      nextProps.query.resultsKey !== this.props.query.resultsKey
+      query.resultsKey &&
+      prevQuery?.resultsKey &&
+      query.resultsKey !== prevQuery.resultsKey
     ) {
-      this.fetchResults(nextProps.query);
+      fetchResults(query);
     }
-  }
+  }, [query, cache]);
 
-  calculateAlertRefHeight = (alertElement: HTMLElement | null) => {
+  const calculateAlertRefHeight = (alertElement: HTMLElement | null) => {
     if (alertElement) {
-      this.setState({ alertIsOpen: true });
+      setAlertIsOpen(true);
     } else {
-      this.setState({ alertIsOpen: false });
+      setAlertIsOpen(false);
     }
   };
 
-  clearQueryResults(query: QueryResponse) {
-    this.props.actions.clearQueryResults(query);
-  }
-
-  popSelectStar(tempSchema: string | null, tempTable: string) {
+  const popSelectStar = (tempSchema: string | null, tempTable: string) => {
     const qe = {
       id: shortid.generate(),
       name: tempTable,
       autorun: false,
-      dbId: this.props.query.dbId,
+      dbId: query.dbId,
       sql: `SELECT * FROM ${tempSchema ? `${tempSchema}.` : ''}${tempTable}`,
     };
-    this.props.actions.addQueryEditor(qe);
-  }
-
-  toggleExploreResultsButton() {
-    this.setState(prevState => ({
-      showExploreResultsButton: !prevState.showExploreResultsButton,
-    }));
-  }
-
-  changeSearch(event: React.ChangeEvent<HTMLInputElement>) {
-    this.setState({ searchText: event.target.value });
-  }
-
-  fetchResults(query: QueryResponse) {
-    this.props.actions.fetchQueryResults(query, this.props.displayLimit);
-  }
-
-  reFetchQueryResults(query: QueryResponse) {
-    this.props.actions.reFetchQueryResults(query);
-  }
+    dispatch(addQueryEditor(qe));
+  };
 
-  reRunQueryIfSessionTimeoutErrorOnMount() {
-    const { query } = this.props;
-    if (
-      query.errorMessage &&
-      query.errorMessage.indexOf('session timed out') > 0
-    ) {
-      this.props.actions.reRunQuery(query);
-    }
-  }
+  const changeSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setSearchText(event.target.value);
+  };
 
-  createExploreResultsOnClick = async () => {
-    const { results } = this.props.query;
+  const createExploreResultsOnClick = async () => {
+    const { results } = query;
 
     if (results?.query_id) {
       const key = await postFormData(results.query_id, 'query', {
@@ -248,16 +216,14 @@ export default class ResultSet extends React.PureComponent<
     }
   };
 
-  renderControls() {
-    if (this.props.search || this.props.visualize || this.props.csv) {
-      let { data } = this.props.query.results;
-      if (this.props.cache && this.props.query.cached) {
-        ({ data } = this.state);
+  const renderControls = () => {
+    if (search || visualize || csv) {
+      let { data } = query.results;
+      if (cache && query.cached) {
+        data = cachedData;
       }
-      const { columns } = this.props.query.results;
+      const { columns } = query.results;
       // Added compute logic to stop user from being able to Save & Explore
-      const { showSaveDatasetModal } = this.state;
-      const { query } = this.props;
 
       const datasource: ISaveableDatasource = {
         columns: query.results.columns as ISimpleColumn[],
@@ -272,7 +238,7 @@ export default class ResultSet extends React.PureComponent<
         <ResultSetControls>
           <SaveDatasetModal
             visible={showSaveDatasetModal}
-            onHide={() => this.setState({ showSaveDatasetModal: false })}
+            onHide={() => setShowSaveDatasetModal(false)}
             buttonTextOnSave={t('Save & Explore')}
             buttonTextOnOverwrite={t('Overwrite & Explore')}
             modalDescription={t(
@@ -281,14 +247,13 @@ export default class ResultSet extends React.PureComponent<
             datasource={datasource}
           />
           <ResultSetButtons>
-            {this.props.visualize &&
-              this.props.database?.allows_virtual_table_explore && (
-                <ExploreResultsButton
-                  database={this.props.database}
-                  onClick={this.createExploreResultsOnClick}
-                />
-              )}
-            {this.props.csv && (
+            {visualize && database?.allows_virtual_table_explore && (
+              <ExploreResultsButton
+                database={database}
+                onClick={createExploreResultsOnClick}
+              />
+            )}
+            {csv && (
               <Button buttonSize="small" href={`/superset/csv/${query.id}`}>
                 <i className="fa fa-file-text-o" /> {t('Download to CSV')}
               </Button>
@@ -305,11 +270,11 @@ export default class ResultSet extends React.PureComponent<
               hideTooltip
             />
           </ResultSetButtons>
-          {this.props.search && (
+          {search && (
             <input
               type="text"
-              onChange={this.changeSearch}
-              value={this.state.searchText}
+              onChange={changeSearch}
+              value={searchText}
               className="form-control input-sm"
               disabled={columns.length > MAX_COLUMNS_FOR_TABLE}
               placeholder={
@@ -323,14 +288,14 @@ export default class ResultSet extends React.PureComponent<
       );
     }
     return <div />;
-  }
+  };
 
-  renderRowsReturned() {
-    const { results, rows, queryLimit, limitingFactor } = this.props.query;
+  const renderRowsReturned = () => {
+    const { results, rows, queryLimit, limitingFactor } = query;
     let limitMessage;
     const limitReached = results?.displayLimitReached;
     const limit = queryLimit || results.query.limit;
-    const isAdmin = !!this.props.user?.roles?.Admin;
+    const isAdmin = !!user?.roles?.Admin;
     const rowsCount = Math.min(rows || 0, results?.data?.length || 0);
 
     const displayMaxRowsReachedMessage = {
@@ -348,10 +313,10 @@ export default class ResultSet extends React.PureComponent<
       ),
     };
     const shouldUseDefaultDropdownAlert =
-      limit === this.props.defaultQueryLimit &&
+      limit === defaultQueryLimit &&
       limitingFactor === LIMITING_FACTOR.DROPDOWN;
 
-    if (limitingFactor === LIMITING_FACTOR.QUERY && this.props.csv) {
+    if (limitingFactor === LIMITING_FACTOR.QUERY && csv) {
       limitMessage = t(
         'The number of rows displayed is limited to %(rows)d by the query',
         { rows },
@@ -386,11 +351,11 @@ export default class ResultSet extends React.PureComponent<
           </span>
         )}
         {!limitReached && shouldUseDefaultDropdownAlert && (
-          <div ref={this.calculateAlertRefHeight}>
+          <div ref={calculateAlertRefHeight}>
             <Alert
               type="warning"
               message={t('%(rows)d rows returned', { rows })}
-              onClose={() => this.setState({ alertIsOpen: false })}
+              onClose={() => setAlertIsOpen(false)}
               description={t(
                 'The number of rows displayed is limited to %s by the dropdown.',
                 rows,
@@ -399,10 +364,10 @@ export default class ResultSet extends React.PureComponent<
           </div>
         )}
         {limitReached && (
-          <div ref={this.calculateAlertRefHeight}>
+          <div ref={calculateAlertRefHeight}>
             <Alert
               type="warning"
-              onClose={() => this.setState({ alertIsOpen: false })}
+              onClose={() => setAlertIsOpen(false)}
               message={t('%(rows)d rows returned', { rows: rowsCount })}
               description={
                 isAdmin
@@ -414,193 +379,191 @@ export default class ResultSet extends React.PureComponent<
         )}
       </ReturnedRows>
     );
+  };
+
+  const limitReached = query?.results?.displayLimitReached;
+  let sql;
+  let exploreDBId = query.dbId;
+  if (database?.explore_database_id) {
+    exploreDBId = database.explore_database_id;
   }
 
-  render() {
-    const { query } = this.props;
-    const limitReached = query?.results?.displayLimitReached;
-    let sql;
-    let exploreDBId = query.dbId;
-    if (this.props.database && this.props.database.explore_database_id) {
-      exploreDBId = this.props.database.explore_database_id;
-    }
-    let trackingUrl;
-    if (
-      query.trackingUrl &&
-      query.state !== 'success' &&
-      query.state !== 'fetching'
-    ) {
-      trackingUrl = (
-        <Button
-          className="sql-result-track-job"
-          buttonSize="small"
-          href={query.trackingUrl}
-          target="_blank"
-        >
-          {query.state === 'running' ? t('Track job') : t('See query details')}
-        </Button>
-      );
-    }
+  let trackingUrl;
+  if (
+    query.trackingUrl &&
+    query.state !== 'success' &&
+    query.state !== 'fetching'
+  ) {
+    trackingUrl = (
+      <Button
+        className="sql-result-track-job"
+        buttonSize="small"
+        href={query.trackingUrl}
+        target="_blank"
+      >
+        {query.state === 'running' ? t('Track job') : t('See query details')}
+      </Button>
+    );
+  }
 
-    if (this.props.showSql) sql = <HighlightedSql sql={query.sql} />;
+  if (showSql) {
+    sql = <HighlightedSql sql={query.sql} />;
+  }
+
+  if (query.state === 'stopped') {
+    return <Alert type="warning" message={t('Query was stopped')} />;
+  }
+
+  if (query.state === 'failed') {
+    return (
+      <ResultlessStyles>
+        <ErrorMessageWithStackTrace
+          title={t('Database error')}
+          error={query?.errors?.[0]}
+          subtitle={<MonospaceDiv>{query.errorMessage}</MonospaceDiv>}
+          copyText={query.errorMessage || undefined}
+          link={query.link}
+          source="sqllab"
+        />
+        {trackingUrl}
+      </ResultlessStyles>
+    );
+  }
 
-    if (query.state === 'stopped') {
-      return <Alert type="warning" message={t('Query was stopped')} />;
+  if (query.state === 'success' && query.ctas) {
+    const { tempSchema, tempTable } = query;
+    let object = 'Table';
+    if (query.ctas_method === CtasEnum.VIEW) {
+      object = 'View';
     }
-    if (query.state === 'failed') {
-      return (
-        <ResultlessStyles>
-          <ErrorMessageWithStackTrace
-            title={t('Database error')}
-            error={query?.errors?.[0]}
-            subtitle={<MonospaceDiv>{query.errorMessage}</MonospaceDiv>}
-            copyText={query.errorMessage || undefined}
-            link={query.link}
-            source="sqllab"
-          />
-          {trackingUrl}
-        </ResultlessStyles>
-      );
+    return (
+      <div>
+        <Alert
+          type="info"
+          message={
+            <>
+              {t(object)} [
+              <strong>
+                {tempSchema ? `${tempSchema}.` : ''}
+                {tempTable}
+              </strong>
+              ] {t('was created')} &nbsp;
+              <ButtonGroup>
+                <Button
+                  buttonSize="small"
+                  className="m-r-5"
+                  onClick={() => popSelectStar(tempSchema, tempTable)}
+                >
+                  {t('Query in a new tab')}
+                </Button>
+                <ExploreCtasResultsButton
+                  table={tempTable}
+                  schema={tempSchema}
+                  dbId={exploreDBId}
+                />
+              </ButtonGroup>
+            </>
+          }
+        />
+      </div>
+    );
+  }
+
+  if (query.state === 'success' && query.results) {
+    const { results } = query;
+    // Accounts for offset needed for height of ResultSetRowsReturned component if !limitReached
+    const rowMessageHeight = !limitReached ? 32 : 0;
+    // Accounts for offset needed for height of Alert if this.state.alertIsOpen
+    const alertContainerHeight = 70;
+    // We need to calculate the height of this.renderRowsReturned()
+    // if we want results panel to be proper height because the
+    // FilterTable component needs an explicit height to render
+    // react-virtualized Table component
+    const rowsHeight = alertIsOpen
+      ? height - alertContainerHeight
+      : height - rowMessageHeight;
+    let data;
+    if (cache && query.cached) {
+      data = cachedData;
+    } else if (results?.data) {
+      ({ data } = results);
     }
-    if (query.state === 'success' && query.ctas) {
-      const { tempSchema, tempTable } = query;
-      let object = 'Table';
-      if (query.ctas_method === CtasEnum.VIEW) {
-        object = 'View';
-      }
+    if (data && data.length > 0) {
+      const expandedColumns = results.expanded_columns
+        ? results.expanded_columns.map(col => col.name)
+        : [];
       return (
-        <div>
-          <Alert
-            type="info"
-            message={
-              <>
-                {t(object)} [
-                <strong>
-                  {tempSchema ? `${tempSchema}.` : ''}
-                  {tempTable}
-                </strong>
-                ] {t('was created')} &nbsp;
-                <ButtonGroup>
-                  <Button
-                    buttonSize="small"
-                    className="m-r-5"
-                    onClick={() => this.popSelectStar(tempSchema, tempTable)}
-                  >
-                    {t('Query in a new tab')}
-                  </Button>
-                  <ExploreCtasResultsButton
-                    // @ts-ignore Redux types are difficult to work with, ignoring for now
-                    actions={this.props.actions}
-                    table={tempTable}
-                    schema={tempSchema}
-                    dbId={exploreDBId}
-                  />
-                </ButtonGroup>
-              </>
-            }
+        <>
+          {renderControls()}
+          {renderRowsReturned()}
+          {sql}
+          <FilterableTable
+            data={data}
+            orderedColumnKeys={results.columns.map(col => col.name)}
+            height={rowsHeight}
+            filterText={searchText}
+            expandedColumns={expandedColumns}
           />
-        </div>
+        </>
       );
     }
-    if (query.state === 'success' && query.results) {
-      const { results } = query;
-      // Accounts for offset needed for height of ResultSetRowsReturned component if !limitReached
-      const rowMessageHeight = !limitReached ? 32 : 0;
-      // Accounts for offset needed for height of Alert if this.state.alertIsOpen
-      const alertContainerHeight = 70;
-      // We need to calculate the height of this.renderRowsReturned()
-      // if we want results panel to be propper height because the
-      // FilterTable component nedds an explcit height to render
-      // react-virtualized Table component
-      const height = this.state.alertIsOpen
-        ? this.props.height - alertContainerHeight
-        : this.props.height - rowMessageHeight;
-      let data;
-      if (this.props.cache && query.cached) {
-        ({ data } = this.state);
-      } else if (results && results.data) {
-        ({ data } = results);
-      }
-      if (data && data.length > 0) {
-        const expandedColumns = results.expanded_columns
-          ? results.expanded_columns.map(col => col.name)
-          : [];
-        return (
-          <>
-            {this.renderControls()}
-            {this.renderRowsReturned()}
-            {sql}
-            <FilterableTable
-              data={data}
-              orderedColumnKeys={results.columns.map(col => col.name)}
-              height={height}
-              filterText={this.state.searchText}
-              expandedColumns={expandedColumns}
-            />
-          </>
-        );
-      }
-      if (data && data.length === 0) {
-        return (
-          <Alert type="warning" message={t('The query returned no data')} />
-        );
-      }
+    if (data && data.length === 0) {
+      return <Alert type="warning" message={t('The query returned no data')} />;
     }
-    if (query.cached || (query.state === 'success' && !query.results)) {
-      if (query.isDataPreview) {
-        return (
-          <Button
-            buttonSize="small"
-            buttonStyle="primary"
-            onClick={() =>
-              this.reFetchQueryResults({
+  }
+
+  if (query.cached || (query.state === 'success' && !query.results)) {
+    if (query.isDataPreview) {
+      return (
+        <Button
+          buttonSize="small"
+          buttonStyle="primary"
+          onClick={() =>
+            dispatch(
+              reFetchQueryResults({
                 ...query,
                 isDataPreview: true,
-              })
-            }
-          >
-            {t('Fetch data preview')}
-          </Button>
-        );
-      }
-      if (query.resultsKey) {
-        return (
-          <Button
-            buttonSize="small"
-            buttonStyle="primary"
-            onClick={() => this.fetchResults(query)}
-          >
-            {t('Refetch results')}
-          </Button>
-        );
-      }
+              }),
+            )
+          }
+        >
+          {t('Fetch data preview')}
+        </Button>
+      );
     }
-    let progressBar;
-    if (query.progress > 0) {
-      progressBar = (
-        <ProgressBar
-          percent={parseInt(query.progress.toFixed(0), 10)}
-          striped
-        />
+    if (query.resultsKey) {
+      return (
+        <Button
+          buttonSize="small"
+          buttonStyle="primary"
+          onClick={() => fetchResults(query)}
+        >
+          {t('Refetch results')}
+        </Button>
       );
     }
-    const progressMsg =
-      query && query.extra && query.extra.progress
-        ? query.extra.progress
-        : null;
+  }
 
-    return (
-      <ResultlessStyles>
-        <div>{!progressBar && <Loading position="normal" />}</div>
-        {/* show loading bar whenever progress bar is completed but needs time to render */}
-        <div>{query.progress === 100 && <Loading position="normal" />}</div>
-        <QueryStateLabel query={query} />
-        <div>
-          {progressMsg && <Alert type="success" message={progressMsg} />}
-        </div>
-        <div>{query.progress !== 100 && progressBar}</div>
-        {trackingUrl && <div>{trackingUrl}</div>}
-      </ResultlessStyles>
+  let progressBar;
+  if (query.progress > 0) {
+    progressBar = (
+      <ProgressBar percent={parseInt(query.progress.toFixed(0), 10)} striped />
     );
   }
-}
+
+  const progressMsg = query?.extra?.progress ?? null;
+
+  return (
+    <ResultlessStyles>
+      <div>{!progressBar && <Loading position="normal" />}</div>
+      {/* show loading bar whenever progress bar is completed but needs time to render */}
+      <div>{query.progress === 100 && <Loading position="normal" />}</div>
+      <QueryStateLabel query={query} />
+      <div>{progressMsg && <Alert type="success" message={progressMsg} />}</div>
+      <div>{query.progress !== 100 && progressBar}</div>
+      {trackingUrl && <div>{trackingUrl}</div>}
+    </ResultlessStyles>
+  );
+};
+
+export default ResultSet;
diff --git a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
index ddcd972f98..2be88f6fe2 100644
--- a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx
@@ -164,10 +164,8 @@ export default function SouthPane({
       if (Date.now() - latestQuery.startDttm <= LOCALSTORAGE_MAX_QUERY_AGE_MS) {
         results = (
           <ResultSet
-            showControls
             search
             query={latestQuery}
-            actions={actions}
             user={user}
             height={innerTabContentHeight + EXTRA_HEIGHT_RESULTS}
             database={databases[latestQuery.dbId]}
@@ -199,7 +197,6 @@ export default function SouthPane({
           query={query}
           visualize={false}
           csv={false}
-          actions={actions}
           cache
           user={user}
           height={innerTabContentHeight}
diff --git a/superset-frontend/src/components/FilterableTable/index.tsx b/superset-frontend/src/components/FilterableTable/index.tsx
index 8324e93b90..16ae37e671 100644
--- a/superset-frontend/src/components/FilterableTable/index.tsx
+++ b/superset-frontend/src/components/FilterableTable/index.tsx
@@ -578,7 +578,7 @@ const FilterableTable = ({
 
     // fix height of filterable table
     return (
-      <StyledFilterableTable>
+      <StyledFilterableTable data-test="grid-container">
         <ScrollSync>
           {({ onScroll, scrollLeft }) => (
             <>
@@ -659,6 +659,7 @@ const FilterableTable = ({
     return (
       <StyledFilterableTable
         className="filterable-table-container"
+        data-test="table-container"
         ref={container}
       >
         {fitted && (
diff --git a/superset-frontend/src/components/ProgressBar/index.tsx b/superset-frontend/src/components/ProgressBar/index.tsx
index 93b4315e52..ba69fc90c6 100644
--- a/superset-frontend/src/components/ProgressBar/index.tsx
+++ b/superset-frontend/src/components/ProgressBar/index.tsx
@@ -27,7 +27,7 @@ export interface ProgressBarProps extends ProgressProps {
 
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const ProgressBar = styled(({ striped, ...props }: ProgressBarProps) => (
-  <AntdProgress {...props} />
+  <AntdProgress data-test="progress-bar" {...props} />
 ))`
   line-height: 0;
   position: static;