You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2024/02/06 19:26:57 UTC

(superset) branch master updated: chore(sqllab): migrate to typescript (#26171)

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

rusackas 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 14f88e3f89 chore(sqllab): migrate to typescript (#26171)
14f88e3f89 is described below

commit 14f88e3f895d9d0c94dcf3a12eafdd8cf7337c94
Author: JUST.in DO IT <ju...@airbnb.com>
AuthorDate: Tue Feb 6 11:26:50 2024 -0800

    chore(sqllab): migrate to typescript (#26171)
---
 .../superset-ui-core/src/query/types/Query.ts      |   1 +
 superset-frontend/spec/helpers/reducerIndex.ts     |   4 +-
 .../components/App/{App.test.jsx => App.test.tsx}  |  31 ++-
 .../SqlLab/components/App/{index.jsx => index.tsx} |  52 +++--
 .../src/SqlLab/components/SqlEditor/index.tsx      |   5 +-
 ...rLeftBar.test.jsx => SqlEditorLeftBar.test.tsx} | 177 ++++++++--------
 .../SqlLab/components/SqlEditorLeftBar/index.tsx   |  23 +--
 .../TabbedSqlEditors/TabbedSqlEditors.test.jsx     | 228 ---------------------
 .../TabbedSqlEditors/TabbedSqlEditors.test.tsx     | 178 ++++++++++++++++
 .../TabbedSqlEditors/{index.jsx => index.tsx}      |  95 ++++-----
 superset-frontend/src/SqlLab/fixtures.ts           |   2 +-
 superset-frontend/src/SqlLab/types.ts              |  19 +-
 ...ryResults.test.js => emptyQueryResults.test.ts} |   7 +-
 ...Helper.js => reduxStateToLocalStorageHelper.ts} |  18 +-
 .../src/components/EmptyState/index.tsx            |   2 +-
 superset-frontend/src/types/bootstrapTypes.ts      |   1 +
 16 files changed, 413 insertions(+), 430 deletions(-)

diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
index 488caaa600..8999a2b574 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/types/Query.ts
@@ -328,6 +328,7 @@ export type Query = {
   actions: Record<string, any>;
   type: DatasourceType;
   columns: QueryColumn[];
+  runAsync?: boolean;
 };
 
 export type QueryResults = {
diff --git a/superset-frontend/spec/helpers/reducerIndex.ts b/superset-frontend/spec/helpers/reducerIndex.ts
index 95fe4d3f1c..a4e0021839 100644
--- a/superset-frontend/spec/helpers/reducerIndex.ts
+++ b/superset-frontend/spec/helpers/reducerIndex.ts
@@ -39,8 +39,8 @@ const common = { ...bootstrapData.common };
 const user = { ...bootstrapData.user };
 
 const noopReducer =
-  (initialState: unknown) =>
-  (state = initialState) =>
+  <STATE = unknown>(initialState: STATE) =>
+  (state: STATE = initialState) =>
     state;
 
 export default {
diff --git a/superset-frontend/src/SqlLab/components/App/App.test.jsx b/superset-frontend/src/SqlLab/components/App/App.test.tsx
similarity index 86%
rename from superset-frontend/src/SqlLab/components/App/App.test.jsx
rename to superset-frontend/src/SqlLab/components/App/App.test.tsx
index d3db1d5fb8..b609419cb1 100644
--- a/superset-frontend/src/SqlLab/components/App/App.test.jsx
+++ b/superset-frontend/src/SqlLab/components/App/App.test.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 import React from 'react';
-import { combineReducers } from 'redux';
+import { AnyAction, combineReducers } from 'redux';
 import configureStore from 'redux-mock-store';
 import thunk from 'redux-thunk';
 import { render } from 'spec/helpers/testing-library';
@@ -38,18 +38,15 @@ jest.mock('src/SqlLab/components/QueryAutoRefresh', () => () => (
   <div data-test="mock-query-auto-refresh" />
 ));
 
-const sqlLabReducer = combineReducers(reducers);
+const sqlLabReducer = combineReducers({
+  localStorageUsageInKilobytes: reducers.localStorageUsageInKilobytes,
+});
+const mockAction = {} as AnyAction;
 
 describe('SqlLab App', () => {
   const middlewares = [thunk];
   const mockStore = configureStore(middlewares);
-  const store = mockStore(sqlLabReducer(undefined, {}), {});
-  beforeEach(() => {
-    jest.useFakeTimers();
-  });
-  afterEach(() => {
-    jest.useRealTimers();
-  });
+  const store = mockStore(sqlLabReducer(undefined, mockAction));
 
   it('is valid', () => {
     expect(React.isValidElement(<App />)).toBe(true);
@@ -61,15 +58,13 @@ describe('SqlLab App', () => {
     expect(getByTestId('mock-tabbed-sql-editors')).toBeInTheDocument();
   });
 
-  it('logs current usage warning', () => {
+  it('logs current usage warning', async () => {
     const localStorageUsageInKilobytes = LOCALSTORAGE_MAX_USAGE_KB + 10;
+    const initialState = {
+      localStorageUsageInKilobytes,
+    };
     const storeExceedLocalStorage = mockStore(
-      sqlLabReducer(
-        {
-          localStorageUsageInKilobytes,
-        },
-        {},
-      ),
+      sqlLabReducer(initialState, mockAction),
     );
 
     const { rerender } = render(<App />, {
@@ -87,14 +82,14 @@ describe('SqlLab App', () => {
     );
   });
 
-  it('logs current local storage usage', () => {
+  it('logs current local storage usage', async () => {
     const localStorageUsageInKilobytes = LOCALSTORAGE_MAX_USAGE_KB - 10;
     const storeExceedLocalStorage = mockStore(
       sqlLabReducer(
         {
           localStorageUsageInKilobytes,
         },
-        {},
+        mockAction,
       ),
     );
 
diff --git a/superset-frontend/src/SqlLab/components/App/index.jsx b/superset-frontend/src/SqlLab/components/App/index.tsx
similarity index 85%
rename from superset-frontend/src/SqlLab/components/App/index.jsx
rename to superset-frontend/src/SqlLab/components/App/index.tsx
index b830454e19..4d2e1d222c 100644
--- a/superset-frontend/src/SqlLab/components/App/index.jsx
+++ b/superset-frontend/src/SqlLab/components/App/index.tsx
@@ -17,8 +17,6 @@
  * under the License.
  */
 import React from 'react';
-import PropTypes from 'prop-types';
-import { bindActionCreators } from 'redux';
 import { connect } from 'react-redux';
 import { Redirect } from 'react-router-dom';
 import { css, styled, t } from '@superset-ui/core';
@@ -28,7 +26,8 @@ import {
   LOCALSTORAGE_WARNING_THRESHOLD,
   LOCALSTORAGE_WARNING_MESSAGE_THROTTLE_MS,
 } from 'src/SqlLab/constants';
-import * as Actions from 'src/SqlLab/actions/sqlLab';
+import { addDangerToast } from 'src/components/MessageToasts/actions';
+import type { SqlLabRootState } from 'src/SqlLab/types';
 import { logEvent } from 'src/logger/actions';
 import {
   LOG_ACTIONS_SQLLAB_WARN_LOCAL_STORAGE_USAGE,
@@ -100,8 +99,21 @@ const SqlLabStyles = styled.div`
   `};
 `;
 
-class App extends React.PureComponent {
-  constructor(props) {
+type PureProps = {
+  // add this for testing componentDidUpdate spec
+  updated?: boolean;
+};
+
+type AppProps = ReturnType<typeof mergeProps> & PureProps;
+
+interface AppState {
+  hash: string;
+}
+
+class App extends React.PureComponent<AppProps, AppState> {
+  hasLoggedLocalStorageUsage: boolean;
+
+  constructor(props: AppProps) {
     super(props);
     this.state = {
       hash: window.location.hash,
@@ -125,7 +137,7 @@ class App extends React.PureComponent {
 
   componentDidUpdate() {
     const { localStorageUsageInKilobytes, actions, queries } = this.props;
-    const queryCount = queries?.lenghth || 0;
+    const queryCount = Object.keys(queries || {}).length || 0;
     if (
       localStorageUsageInKilobytes >=
       LOCALSTORAGE_WARNING_THRESHOLD * LOCALSTORAGE_MAX_USAGE_KB
@@ -159,7 +171,7 @@ class App extends React.PureComponent {
     this.setState({ hash: window.location.hash });
   }
 
-  showLocalStorageUsageWarning(currentUsage, queryCount) {
+  showLocalStorageUsageWarning(currentUsage: number, queryCount: number) {
     this.props.actions.addDangerToast(
       t(
         "SQL Lab uses your browser's local storage to store queries and results." +
@@ -190,7 +202,6 @@ class App extends React.PureComponent {
         <Redirect
           to={{
             pathname: '/sqllab/history/',
-            replace: true,
           }}
         />
       );
@@ -207,13 +218,7 @@ class App extends React.PureComponent {
   }
 }
 
-App.propTypes = {
-  actions: PropTypes.object,
-  common: PropTypes.object,
-  localStorageUsageInKilobytes: PropTypes.number.isRequired,
-};
-
-function mapStateToProps(state) {
+function mapStateToProps(state: SqlLabRootState) {
   const { common, localStorageUsageInKilobytes, sqlLab } = state;
   return {
     common,
@@ -223,10 +228,21 @@ function mapStateToProps(state) {
   };
 }
 
-function mapDispatchToProps(dispatch) {
+const mapDispatchToProps = {
+  addDangerToast,
+  logEvent,
+};
+
+function mergeProps(
+  stateProps: ReturnType<typeof mapStateToProps>,
+  dispatchProps: typeof mapDispatchToProps,
+  state: PureProps,
+) {
   return {
-    actions: bindActionCreators({ ...Actions, logEvent }, dispatch),
+    ...state,
+    ...stateProps,
+    actions: dispatchProps,
   };
 }
 
-export default connect(mapStateToProps, mapDispatchToProps)(App);
+export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(App);
diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
index 8213253685..23de528066 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx
@@ -103,7 +103,7 @@ import SaveQuery, { QueryPayload } from '../SaveQuery';
 import ScheduleQueryButton from '../ScheduleQueryButton';
 import EstimateQueryCostButton from '../EstimateQueryCostButton';
 import ShareSqlLabQuery from '../ShareSqlLabQuery';
-import SqlEditorLeftBar, { ExtendedTable } from '../SqlEditorLeftBar';
+import SqlEditorLeftBar from '../SqlEditorLeftBar';
 import AceEditorWrapper from '../AceEditorWrapper';
 import RunQueryActionButton from '../RunQueryActionButton';
 import QueryLimitSelect from '../QueryLimitSelect';
@@ -215,7 +215,6 @@ const StyledSqlEditor = styled.div`
 const extensionsRegistry = getExtensionsRegistry();
 
 export type Props = {
-  tables: ExtendedTable[];
   queryEditor: QueryEditor;
   defaultQueryLimit: number;
   maxRow: number;
@@ -235,7 +234,6 @@ const elementStyle = (
 });
 
 const SqlEditor: React.FC<Props> = ({
-  tables,
   queryEditor,
   defaultQueryLimit,
   maxRow,
@@ -839,7 +837,6 @@ const SqlEditor: React.FC<Props> = ({
               <SqlEditorLeftBar
                 database={database}
                 queryEditorId={queryEditor.id}
-                tables={tables}
                 setEmptyState={bool => setShowEmptyState(bool)}
               />
             </StyledSidebar>
diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.jsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.tsx
similarity index 54%
rename from superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.jsx
rename to superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.tsx
index 6665091572..f89c842b15 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.jsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.tsx
@@ -20,16 +20,19 @@ import React from 'react';
 import fetchMock from 'fetch-mock';
 import { render, screen, waitFor } from 'spec/helpers/testing-library';
 import userEvent from '@testing-library/user-event';
-import { Provider } from 'react-redux';
-import '@testing-library/jest-dom/extend-expect';
-import SqlEditorLeftBar from 'src/SqlLab/components/SqlEditorLeftBar';
-import { table, initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
-import { api } from 'src/hooks/apiResources/queryApi';
-import { setupStore } from 'src/views/store';
-import reducers from 'spec/helpers/reducerIndex';
+import SqlEditorLeftBar, {
+  SqlEditorLeftBarProps,
+} from 'src/SqlLab/components/SqlEditorLeftBar';
+import {
+  table,
+  initialState,
+  defaultQueryEditor,
+  extraQueryEditor1,
+} from 'src/SqlLab/fixtures';
+import type { RootState } from 'src/views/store';
+import type { Store } from 'redux';
 
 const mockedProps = {
-  tables: [table],
   queryEditorId: defaultQueryEditor.id,
   database: {
     id: 1,
@@ -39,115 +42,117 @@ const mockedProps = {
   height: 0,
 };
 
-let store;
-let actions;
-
-const logAction = () => next => action => {
-  if (typeof action === 'function') {
-    return next(action);
-  }
-  actions.push(action);
-  return next(action);
-};
-
-const createStore = initState =>
-  setupStore({
-    disableDegugger: true,
-    initialState: initState,
-    rootReducers: reducers,
-    middleware: getDefaultMiddleware =>
-      getDefaultMiddleware().concat(api.middleware, logAction),
-  });
-
 beforeEach(() => {
-  store = createStore(initialState);
-  actions = [];
   fetchMock.get('glob:*/api/v1/database/?*', { result: [] });
   fetchMock.get('glob:*/api/v1/database/*/schemas/?*', {
     count: 2,
     result: ['main', 'new_schema'],
   });
   fetchMock.get('glob:*/api/v1/database/*/tables/*', {
-    count: 1,
+    count: 2,
     result: [
       {
         label: 'ab_user',
         value: 'ab_user',
       },
+      {
+        label: 'new_table',
+        value: 'new_table',
+      },
     ],
   });
+  fetchMock.get('glob:*/api/v1/database/*/table/*/*', {
+    status: 200,
+    body: {
+      columns: table.columns,
+    },
+  });
+  fetchMock.get('glob:*/api/v1/database/*/table_extra/*/*', {
+    status: 200,
+    body: {},
+  });
 });
 
 afterEach(() => {
   fetchMock.restore();
-  store.dispatch(api.util.resetApiState());
   jest.clearAllMocks();
 });
 
-const renderAndWait = (props, store) =>
+const renderAndWait = (
+  props: SqlEditorLeftBarProps,
+  store?: Store,
+  initialState?: RootState,
+) =>
   waitFor(() =>
     render(<SqlEditorLeftBar {...props} />, {
       useRedux: true,
+      initialState,
       ...(store && { store }),
     }),
   );
 
-test('is valid', () => {
-  expect(
-    React.isValidElement(
-      <Provider store={store}>
-        <SqlEditorLeftBar {...mockedProps} />
-      </Provider>,
-    ),
-  ).toBe(true);
-});
-
 test('renders a TableElement', async () => {
-  await renderAndWait(mockedProps, store);
-  expect(await screen.findByText(/Database/i)).toBeInTheDocument();
-  const tableElement = screen.getAllByTestId('table-element');
+  const { findByText, getAllByTestId } = await renderAndWait(
+    mockedProps,
+    undefined,
+    { ...initialState, sqlLab: { ...initialState.sqlLab, tables: [table] } },
+  );
+  expect(await findByText(/Database/i)).toBeInTheDocument();
+  const tableElement = getAllByTestId('table-element');
   expect(tableElement.length).toBeGreaterThanOrEqual(1);
 });
 
 test('table should be visible when expanded is true', async () => {
-  const { container } = await renderAndWait(mockedProps, store);
+  const { container, getByText, getByRole, queryAllByText } =
+    await renderAndWait(mockedProps, undefined, {
+      ...initialState,
+      sqlLab: { ...initialState.sqlLab, tables: [table] },
+    });
 
-  const dbSelect = screen.getByRole('combobox', {
+  const dbSelect = getByRole('combobox', {
     name: 'Select database or type to search databases',
   });
-  const schemaSelect = screen.getByRole('combobox', {
+  const schemaSelect = getByRole('combobox', {
     name: 'Select schema or type to search schemas',
   });
-  const dropdown = screen.getByText(/Table/i);
-  const abUser = screen.queryAllByText(/ab_user/i);
-
-  await waitFor(() => {
-    expect(screen.getByText(/Database/i)).toBeInTheDocument();
-    expect(dbSelect).toBeInTheDocument();
-    expect(schemaSelect).toBeInTheDocument();
-    expect(dropdown).toBeInTheDocument();
-    expect(abUser).toHaveLength(2);
-    expect(
-      container.querySelector('.ant-collapse-content-active'),
-    ).toBeInTheDocument();
+  const dropdown = getByText(/Table/i);
+  const abUser = queryAllByText(/ab_user/i);
+
+  expect(getByText(/Database/i)).toBeInTheDocument();
+  expect(dbSelect).toBeInTheDocument();
+  expect(schemaSelect).toBeInTheDocument();
+  expect(dropdown).toBeInTheDocument();
+  expect(abUser).toHaveLength(2);
+  expect(
+    container.querySelector('.ant-collapse-content-active'),
+  ).toBeInTheDocument();
+  table.columns.forEach(({ name }) => {
+    expect(getByText(name)).toBeInTheDocument();
   });
 });
 
 test('should toggle the table when the header is clicked', async () => {
-  await renderAndWait(mockedProps, store);
+  const { container } = await renderAndWait(mockedProps, undefined, {
+    ...initialState,
+    sqlLab: { ...initialState.sqlLab, tables: [table] },
+  });
 
-  const header = (await screen.findAllByText(/ab_user/))[1];
+  const header = container.querySelector('.ant-collapse-header');
   expect(header).toBeInTheDocument();
 
-  userEvent.click(header);
+  if (header) {
+    userEvent.click(header);
+  }
 
-  await waitFor(() => {
-    expect(actions[actions.length - 1].type).toEqual('COLLAPSE_TABLE');
-  });
+  await waitFor(() =>
+    expect(
+      container.querySelector('.ant-collapse-content-inactive'),
+    ).toBeInTheDocument(),
+  );
 });
 
 test('When changing database the table list must be updated', async () => {
-  store = createStore({
+  const { rerender } = await renderAndWait(mockedProps, undefined, {
     ...initialState,
     sqlLab: {
       ...initialState.sqlLab,
@@ -155,9 +160,25 @@ test('When changing database the table list must be updated', async () => {
         id: defaultQueryEditor.id,
         schema: 'new_schema',
       },
+      queryEditors: [
+        defaultQueryEditor,
+        {
+          ...extraQueryEditor1,
+          schema: 'new_schema',
+          dbId: 2,
+        },
+      ],
+      tables: [
+        table,
+        {
+          ...table,
+          dbId: 2,
+          name: 'new_table',
+          queryEditorId: extraQueryEditor1.id,
+        },
+      ],
     },
   });
-  const { rerender } = await renderAndWait(mockedProps, store);
 
   expect(screen.getAllByText(/main/i)[0]).toBeInTheDocument();
   expect(screen.getAllByText(/ab_user/i)[0]).toBeInTheDocument();
@@ -170,21 +191,18 @@ test('When changing database the table list must be updated', async () => {
         database_name: 'new_db',
         backend: 'postgresql',
       }}
-      queryEditorId={defaultQueryEditor.id}
-      tables={[{ ...mockedProps.tables[0], dbId: 2, name: 'new_table' }]}
+      queryEditorId={extraQueryEditor1.id}
     />,
-    {
-      useRedux: true,
-      store,
-    },
   );
-  expect(await screen.findByText(/new_db/i)).toBeInTheDocument();
-  expect(await screen.findByText(/new_table/i)).toBeInTheDocument();
+  const updatedDbSelector = await screen.findAllByText(/new_db/i);
+  expect(updatedDbSelector[0]).toBeInTheDocument();
+  const updatedTableSelector = await screen.findAllByText(/new_table/i);
+  expect(updatedTableSelector[0]).toBeInTheDocument();
 });
 
 test('ignore schema api when current schema is deprecated', async () => {
   const invalidSchemaName = 'None';
-  store = createStore({
+  await renderAndWait(mockedProps, undefined, {
     ...initialState,
     sqlLab: {
       ...initialState.sqlLab,
@@ -192,9 +210,9 @@ test('ignore schema api when current schema is deprecated', async () => {
         id: defaultQueryEditor.id,
         schema: invalidSchemaName,
       },
+      tables: [table],
     },
   });
-  const { rerender } = await renderAndWait(mockedProps, store);
 
   expect(await screen.findByText(/Database/i)).toBeInTheDocument();
   expect(fetchMock.calls()).not.toContainEqual(
@@ -204,7 +222,6 @@ test('ignore schema api when current schema is deprecated', async () => {
       ),
     ]),
   );
-  rerender();
   // Deselect the deprecated schema selection
   await waitFor(() =>
     expect(screen.queryByText(/None/i)).not.toBeInTheDocument(),
diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
index eff67d49b1..15a1735626 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
@@ -24,10 +24,10 @@ import React, {
   Dispatch,
   SetStateAction,
 } from 'react';
-import { useDispatch } from 'react-redux';
+import { shallowEqual, useDispatch, useSelector } from 'react-redux';
 import querystring from 'query-string';
 
-import { Table } from 'src/SqlLab/types';
+import { SqlLabRootState, Table } from 'src/SqlLab/types';
 import {
   queryEditorSetDb,
   addTable,
@@ -55,16 +55,11 @@ import {
 } from 'src/utils/localStorageHelpers';
 import TableElement from '../TableElement';
 
-export interface ExtendedTable extends Table {
-  expanded: boolean;
-}
-
-interface SqlEditorLeftBarProps {
+export interface SqlEditorLeftBarProps {
   queryEditorId: string;
   height?: number;
-  tables?: ExtendedTable[];
   database?: DatabaseObject;
-  setEmptyState: Dispatch<SetStateAction<boolean>>;
+  setEmptyState?: Dispatch<SetStateAction<boolean>>;
 }
 
 const StyledScrollbarContainer = styled.div`
@@ -111,10 +106,14 @@ const LeftBarStyles = styled.div`
 const SqlEditorLeftBar = ({
   database,
   queryEditorId,
-  tables = [],
   height = 500,
   setEmptyState,
 }: SqlEditorLeftBarProps) => {
+  const tables = useSelector<SqlLabRootState, Table[]>(
+    ({ sqlLab }) =>
+      sqlLab.tables.filter(table => table.queryEditorId === queryEditorId),
+    shallowEqual,
+  );
   const dispatch = useDispatch();
   const queryEditor = useQueryEditor(queryEditorId, ['dbId', 'schema']);
 
@@ -144,7 +143,7 @@ const SqlEditorLeftBar = ({
   };
 
   const onDbChange = ({ id: dbId }: { id: number }) => {
-    setEmptyState(false);
+    setEmptyState?.(false);
     dispatch(queryEditorSetDb(queryEditor, dbId));
   };
 
@@ -177,7 +176,7 @@ const SqlEditorLeftBar = ({
   };
 
   const onToggleTable = (updatedTables: string[]) => {
-    tables.forEach((table: ExtendedTable) => {
+    tables.forEach(table => {
       if (!updatedTables.includes(table.id.toString()) && table.expanded) {
         dispatch(collapseTable(table));
       } else if (
diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx
deleted file mode 100644
index 5d782590a1..0000000000
--- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.jsx
+++ /dev/null
@@ -1,228 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import React from 'react';
-import configureStore from 'redux-mock-store';
-import thunk from 'redux-thunk';
-import URI from 'urijs';
-import { Provider } from 'react-redux';
-import { shallow, mount } from 'enzyme';
-import { fireEvent, render, waitFor } from 'spec/helpers/testing-library';
-import sinon from 'sinon';
-import { act } from 'react-dom/test-utils';
-import fetchMock from 'fetch-mock';
-import { supersetTheme, ThemeProvider } from '@superset-ui/core';
-import { EditableTabs } from 'src/components/Tabs';
-import TabbedSqlEditors from 'src/SqlLab/components/TabbedSqlEditors';
-import SqlEditor from 'src/SqlLab/components/SqlEditor';
-import { initialState } from 'src/SqlLab/fixtures';
-import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName';
-
-fetchMock.get('glob:*/api/v1/database/*', {});
-fetchMock.get('glob:*/api/v1/saved_query/*', {});
-fetchMock.get('glob:*/kv/*', {});
-
-describe('TabbedSqlEditors', () => {
-  const middlewares = [thunk];
-  const mockStore = configureStore(middlewares);
-  const store = mockStore(initialState);
-
-  const queryEditors = [
-    {
-      autorun: false,
-      dbId: 1,
-      id: 'newEditorId',
-      latestQueryId: 'B1-VQU1zW',
-      schema: null,
-      selectedText: null,
-      sql: 'SELECT ds...',
-      name: 'Untitled Query',
-    },
-  ];
-  const mockedProps = {
-    actions: {},
-    databases: {},
-    tables: [],
-    queries: {},
-    queryEditors: initialState.sqlLab.queryEditors,
-    tabHistory: initialState.sqlLab.tabHistory,
-    editorHeight: '',
-    getHeight: () => '100px',
-    database: {},
-    defaultQueryLimit: 1000,
-    maxRow: 100000,
-  };
-
-  const getWrapper = () =>
-    shallow(<TabbedSqlEditors store={store} {...mockedProps} />)
-      .dive()
-      .dive();
-
-  const mountWithAct = async () =>
-    act(async () => {
-      mount(
-        <Provider store={store}>
-          <TabbedSqlEditors {...mockedProps} />
-        </Provider>,
-        {
-          wrappingComponent: ThemeProvider,
-          wrappingComponentProps: { theme: supersetTheme },
-        },
-      );
-    });
-  const setup = (props = {}, overridesStore) =>
-    render(<TabbedSqlEditors {...props} />, {
-      useRedux: true,
-      store: overridesStore || store,
-    });
-
-  let wrapper;
-  it('is valid', () => {
-    expect(React.isValidElement(<TabbedSqlEditors {...mockedProps} />)).toBe(
-      true,
-    );
-  });
-  describe('componentDidMount', () => {
-    let uriStub;
-    beforeEach(() => {
-      sinon.stub(window.history, 'replaceState');
-      uriStub = sinon.stub(URI.prototype, 'search');
-    });
-    afterEach(() => {
-      window.history.replaceState.restore();
-      uriStub.restore();
-    });
-    it('should handle id', async () => {
-      uriStub.returns({ id: 1 });
-      await mountWithAct();
-      expect(window.history.replaceState.getCall(0).args[2]).toBe('/sqllab');
-    });
-    it('should handle savedQueryId', async () => {
-      uriStub.returns({ savedQueryId: 1 });
-      await mountWithAct();
-      expect(window.history.replaceState.getCall(0).args[2]).toBe('/sqllab');
-    });
-    it('should handle sql', async () => {
-      uriStub.returns({ sql: 1, dbid: 1 });
-      await mountWithAct();
-      expect(window.history.replaceState.getCall(0).args[2]).toBe('/sqllab');
-    });
-    it('should handle custom url params', async () => {
-      uriStub.returns({
-        sql: 1,
-        dbid: 1,
-        custom_value: 'str',
-        extra_attr1: 'true',
-      });
-      await mountWithAct();
-      expect(window.history.replaceState.getCall(0).args[2]).toBe(
-        '/sqllab?custom_value=str&extra_attr1=true',
-      );
-    });
-  });
-  it('should removeQueryEditor', () => {
-    wrapper = getWrapper();
-    sinon.stub(wrapper.instance().props.actions, 'removeQueryEditor');
-
-    wrapper.instance().removeQueryEditor(queryEditors[0]);
-    expect(
-      wrapper.instance().props.actions.removeQueryEditor.getCall(0).args[0],
-    ).toBe(queryEditors[0]);
-  });
-  it('should add new query editor', async () => {
-    const { getAllByLabelText } = setup(mockedProps);
-    fireEvent.click(getAllByLabelText('Add tab')[0]);
-    const actions = store.getActions();
-    await waitFor(() =>
-      expect(actions).toContainEqual({
-        type: 'ADD_QUERY_EDITOR',
-        queryEditor: expect.objectContaining({
-          name: expect.stringMatching(/Untitled Query (\d+)+/),
-        }),
-      }),
-    );
-  });
-  it('should properly increment query tab name', async () => {
-    const { getAllByLabelText } = setup(mockedProps);
-    const newTitle = newQueryTabName(store.getState().sqlLab.queryEditors);
-    fireEvent.click(getAllByLabelText('Add tab')[0]);
-    const actions = store.getActions();
-    await waitFor(() =>
-      expect(actions).toContainEqual({
-        type: 'ADD_QUERY_EDITOR',
-        queryEditor: expect.objectContaining({
-          name: newTitle,
-        }),
-      }),
-    );
-  });
-  it('should duplicate query editor', () => {
-    wrapper = getWrapper();
-    sinon.stub(wrapper.instance().props.actions, 'cloneQueryToNewTab');
-
-    wrapper.instance().duplicateQueryEditor(queryEditors[0]);
-    expect(
-      wrapper.instance().props.actions.cloneQueryToNewTab.getCall(0).args[0],
-    ).toBe(queryEditors[0]);
-  });
-  it('should handle select', () => {
-    const mockEvent = {
-      target: {
-        getAttribute: () => null,
-      },
-    };
-    wrapper = getWrapper();
-    sinon.stub(wrapper.instance().props.actions, 'switchQueryEditor');
-
-    // cannot switch to current tab, switchQueryEditor never gets called
-    wrapper.instance().handleSelect('dfsadfs', mockEvent);
-    expect(
-      wrapper.instance().props.actions.switchQueryEditor.callCount,
-    ).toEqual(0);
-  });
-  it('should handle add tab', () => {
-    wrapper = getWrapper();
-    sinon.spy(wrapper.instance(), 'newQueryEditor');
-
-    wrapper.instance().handleEdit('1', 'add');
-    expect(wrapper.instance().newQueryEditor.callCount).toBe(1);
-    wrapper.instance().newQueryEditor.restore();
-  });
-  it('should render', () => {
-    wrapper = getWrapper();
-    wrapper.setState({ hideLeftBar: true });
-
-    const firstTab = wrapper.find(EditableTabs.TabPane).first();
-    expect(firstTab.props()['data-key']).toContain(
-      initialState.sqlLab.queryEditors[0].id,
-    );
-    expect(firstTab.find(SqlEditor)).toHaveLength(1);
-  });
-  it('should disable new tab when offline', () => {
-    wrapper = getWrapper();
-    expect(wrapper.find('#a11y-query-editor-tabs').props().hideAdd).toBe(false);
-    wrapper.setProps({ offline: true });
-    expect(wrapper.find('#a11y-query-editor-tabs').props().hideAdd).toBe(true);
-  });
-  it('should have an empty state when query editors is empty', () => {
-    wrapper = getWrapper();
-    wrapper.setProps({ queryEditors: [] });
-    const firstTab = wrapper.find(EditableTabs.TabPane).first();
-    expect(firstTab.props()['data-key']).toBe(0);
-  });
-});
diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.tsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.tsx
new file mode 100644
index 0000000000..6b048830e8
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/TabbedSqlEditors.test.tsx
@@ -0,0 +1,178 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React from 'react';
+import configureStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+import URI from 'urijs';
+import { fireEvent, render, waitFor } from 'spec/helpers/testing-library';
+import fetchMock from 'fetch-mock';
+import TabbedSqlEditors from 'src/SqlLab/components/TabbedSqlEditors';
+import { initialState } from 'src/SqlLab/fixtures';
+import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName';
+import { Store } from 'redux';
+import { RootState } from 'src/views/store';
+import { SET_ACTIVE_QUERY_EDITOR } from 'src/SqlLab/actions/sqlLab';
+
+fetchMock.get('glob:*/api/v1/database/*', {});
+fetchMock.get('glob:*/api/v1/saved_query/*', {});
+fetchMock.get('glob:*/kv/*', {});
+
+jest.mock('src/SqlLab/components/SqlEditor', () => () => (
+  <div data-test="mock-sql-editor" />
+));
+
+const middlewares = [thunk];
+const mockStore = configureStore(middlewares);
+const store = mockStore(initialState);
+
+const setup = (overridesStore?: Store, initialState?: RootState) =>
+  render(<TabbedSqlEditors />, {
+    useRedux: true,
+    initialState,
+    ...(overridesStore && { store: overridesStore }),
+  });
+
+beforeEach(() => {
+  store.clearActions();
+});
+
+describe('componentDidMount', () => {
+  let uriStub = jest.spyOn(URI.prototype, 'search');
+  let replaceState = jest.spyOn(window.history, 'replaceState');
+  beforeEach(() => {
+    replaceState = jest.spyOn(window.history, 'replaceState');
+    uriStub = jest.spyOn(URI.prototype, 'search');
+  });
+  afterEach(() => {
+    replaceState.mockReset();
+    uriStub.mockReset();
+  });
+  test('should handle id', () => {
+    uriStub.mockReturnValue({ id: 1 });
+    setup(store);
+    expect(replaceState).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.anything(),
+      '/sqllab',
+    );
+  });
+  test('should handle savedQueryId', () => {
+    uriStub.mockReturnValue({ savedQueryId: 1 });
+    setup(store);
+    expect(replaceState).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.anything(),
+      '/sqllab',
+    );
+  });
+  test('should handle sql', () => {
+    uriStub.mockReturnValue({ sql: 1, dbid: 1 });
+    setup(store);
+    expect(replaceState).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.anything(),
+      '/sqllab',
+    );
+  });
+  test('should handle custom url params', () => {
+    uriStub.mockReturnValue({
+      sql: 1,
+      dbid: 1,
+      custom_value: 'str',
+      extra_attr1: 'true',
+    });
+    setup(store);
+    expect(replaceState).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.anything(),
+      '/sqllab?custom_value=str&extra_attr1=true',
+    );
+  });
+});
+
+test('should removeQueryEditor', async () => {
+  const { getByRole, getAllByRole, queryByText } = setup(
+    undefined,
+    initialState,
+  );
+  const tabCount = getAllByRole('tab').length;
+  const tabList = getByRole('tablist');
+  const closeButton = tabList.getElementsByTagName('button')[0];
+  expect(closeButton).toBeInTheDocument();
+  if (closeButton) {
+    fireEvent.click(closeButton);
+  }
+  await waitFor(() => expect(getAllByRole('tab').length).toEqual(tabCount - 1));
+  expect(queryByText(initialState.sqlLab.queryEditors[0].name)).toBeFalsy();
+});
+test('should add new query editor', async () => {
+  const { getAllByLabelText, getAllByRole } = setup(undefined, initialState);
+  const tabCount = getAllByRole('tab').length;
+  fireEvent.click(getAllByLabelText('Add tab')[0]);
+  await waitFor(() => expect(getAllByRole('tab').length).toEqual(tabCount + 1));
+  expect(getAllByRole('tab')[tabCount]).toHaveTextContent(
+    /Untitled Query (\d+)+/,
+  );
+});
+test('should properly increment query tab name', async () => {
+  const { getAllByLabelText, getAllByRole } = setup(undefined, initialState);
+  const tabCount = getAllByRole('tab').length;
+  const newTitle = newQueryTabName(initialState.sqlLab.queryEditors);
+  fireEvent.click(getAllByLabelText('Add tab')[0]);
+  await waitFor(() => expect(getAllByRole('tab').length).toEqual(tabCount + 1));
+  expect(getAllByRole('tab')[tabCount]).toHaveTextContent(newTitle);
+});
+test('should handle select', async () => {
+  const { getAllByRole } = setup(store);
+  const tabs = getAllByRole('tab');
+  fireEvent.click(tabs[1]);
+  await waitFor(() => expect(store.getActions()).toHaveLength(1));
+  expect(store.getActions()[0]).toEqual(
+    expect.objectContaining({
+      type: SET_ACTIVE_QUERY_EDITOR,
+      queryEditor: initialState.sqlLab.queryEditors[1],
+    }),
+  );
+});
+test('should render', () => {
+  const { getAllByRole } = setup(store);
+  const tabs = getAllByRole('tab');
+  expect(tabs).toHaveLength(initialState.sqlLab.queryEditors.length);
+});
+test('should disable new tab when offline', () => {
+  const { queryAllByLabelText } = setup(undefined, {
+    ...initialState,
+    sqlLab: {
+      ...initialState.sqlLab,
+      offline: true,
+    },
+  });
+  expect(queryAllByLabelText('Add tab').length).toEqual(0);
+});
+test('should have an empty state when query editors is empty', () => {
+  const { getByText } = setup(undefined, {
+    ...initialState,
+    sqlLab: {
+      ...initialState.sqlLab,
+      queryEditors: [],
+      tabHistory: [],
+    },
+  });
+  expect(getByText('Add a new tab to create SQL Query')).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
similarity index 83%
rename from superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx
rename to superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
index 0bd60fff7e..62ecfb5dcc 100644
--- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.jsx
+++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
@@ -18,11 +18,10 @@
  */
 import React from 'react';
 import { pick } from 'lodash';
-import PropTypes from 'prop-types';
 import { EditableTabs } from 'src/components/Tabs';
 import { connect } from 'react-redux';
-import { bindActionCreators } from 'redux';
 import URI from 'urijs';
+import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
 import { FeatureFlag, styled, t, isFeatureEnabled } from '@superset-ui/core';
 import { Tooltip } from 'src/components/Tooltip';
 import { detectOS } from 'src/utils/common';
@@ -33,22 +32,7 @@ import { locationContext } from 'src/pages/SqlLab/LocationContext';
 import SqlEditor from '../SqlEditor';
 import SqlEditorTabHeader from '../SqlEditorTabHeader';
 
-const propTypes = {
-  actions: PropTypes.object.isRequired,
-  defaultDbId: PropTypes.number,
-  displayLimit: PropTypes.number,
-  defaultQueryLimit: PropTypes.number.isRequired,
-  maxRow: PropTypes.number.isRequired,
-  databases: PropTypes.object.isRequired,
-  queries: PropTypes.object.isRequired,
-  queryEditors: PropTypes.array,
-  tabHistory: PropTypes.array.isRequired,
-  tables: PropTypes.array.isRequired,
-  offline: PropTypes.bool,
-  saveQueryWarning: PropTypes.string,
-  scheduleQueryWarning: PropTypes.string,
-};
-const defaultProps = {
+const DEFAULT_PROPS = {
   queryEditors: [],
   offline: false,
   saveQueryWarning: null,
@@ -73,15 +57,14 @@ const TabTitle = styled.span`
 // Get the user's OS
 const userOS = detectOS();
 
-class TabbedSqlEditors extends React.PureComponent {
-  constructor(props) {
+type TabbedSqlEditorsProps = ReturnType<typeof mergeProps>;
+
+const SQL_LAB_URL = '/sqllab';
+
+class TabbedSqlEditors extends React.PureComponent<TabbedSqlEditorsProps> {
+  constructor(props: TabbedSqlEditorsProps) {
     super(props);
-    const sqlLabUrl = '/sqllab';
-    this.state = {
-      sqlLabUrl,
-    };
     this.removeQueryEditor = this.removeQueryEditor.bind(this);
-    this.duplicateQueryEditor = this.duplicateQueryEditor.bind(this);
     this.handleSelect = this.handleSelect.bind(this);
     this.handleEdit = this.handleEdit.bind(this);
   }
@@ -136,7 +119,7 @@ class TabbedSqlEditors extends React.PureComponent {
       ...this.context.requestedQuery,
       ...bootstrapData.requested_query,
       ...queryParameters,
-    };
+    } as Record<string, string>;
 
     // Popping a new tab based on the querystring
     if (id || sql || savedQueryId || datasourceKey || queryId) {
@@ -149,7 +132,7 @@ class TabbedSqlEditors extends React.PureComponent {
       } else if (datasourceKey) {
         this.props.actions.popDatasourceQuery(datasourceKey, sql);
       } else if (sql) {
-        let databaseId = dbid;
+        let databaseId: string | number = dbid;
         if (databaseId) {
           databaseId = parseInt(databaseId, 10);
         } else {
@@ -177,11 +160,11 @@ class TabbedSqlEditors extends React.PureComponent {
       this.newQueryEditor();
 
       if (isNewQuery) {
-        window.history.replaceState({}, document.title, this.state.sqlLabUrl);
+        window.history.replaceState({}, document.title, SQL_LAB_URL);
       }
     } else {
       const qe = this.activeQueryEditor();
-      const latestQuery = this.props.queries[qe.latestQueryId];
+      const latestQuery = this.props.queries[qe?.latestQueryId || ''];
       if (
         isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) &&
         latestQuery &&
@@ -197,9 +180,9 @@ class TabbedSqlEditors extends React.PureComponent {
     }
   }
 
-  popNewTab(urlParams) {
+  popNewTab(urlParams: Record<string, string>) {
     // Clean the url in browser history
-    const updatedUrl = `${URI(this.state.sqlLabUrl).query(urlParams)}`;
+    const updatedUrl = `${URI(SQL_LAB_URL).query(urlParams)}`;
     window.history.replaceState({}, document.title, updatedUrl);
   }
 
@@ -215,7 +198,7 @@ class TabbedSqlEditors extends React.PureComponent {
     this.props.actions.addNewQueryEditor();
   }
 
-  handleSelect(key) {
+  handleSelect(key: string) {
     const qeid = this.props.tabHistory[this.props.tabHistory.length - 1];
     if (key !== qeid) {
       const queryEditor = this.props.queryEditors.find(qe => qe.id === key);
@@ -229,24 +212,22 @@ class TabbedSqlEditors extends React.PureComponent {
     }
   }
 
-  handleEdit(key, action) {
+  handleEdit(key: string, action: string) {
     if (action === 'remove') {
       const qe = this.props.queryEditors.find(qe => qe.id === key);
-      this.removeQueryEditor(qe);
+      if (qe) {
+        this.removeQueryEditor(qe);
+      }
     }
     if (action === 'add') {
       this.newQueryEditor();
     }
   }
 
-  removeQueryEditor(qe) {
+  removeQueryEditor(qe: QueryEditor) {
     this.props.actions.removeQueryEditor(qe);
   }
 
-  duplicateQueryEditor(qe) {
-    this.props.actions.cloneQueryToNewTab(qe, false);
-  }
-
   render() {
     const noQueryEditors = this.props.queryEditors?.length === 0;
     const editors = this.props.queryEditors?.map(qe => (
@@ -257,7 +238,6 @@ class TabbedSqlEditors extends React.PureComponent {
         data-key={qe.id}
       >
         <SqlEditor
-          tables={this.props.tables.filter(xt => xt.queryEditorId === qe.id)}
           queryEditor={qe}
           defaultQueryLimit={this.props.defaultQueryLimit}
           maxRow={this.props.maxRow}
@@ -332,30 +312,45 @@ class TabbedSqlEditors extends React.PureComponent {
     );
   }
 }
-TabbedSqlEditors.propTypes = propTypes;
-TabbedSqlEditors.defaultProps = defaultProps;
+
 TabbedSqlEditors.contextType = locationContext;
 
-function mapStateToProps({ sqlLab, common }) {
+export function mapStateToProps({ sqlLab, common }: SqlLabRootState) {
   return {
     databases: sqlLab.databases,
-    queryEditors: sqlLab.queryEditors,
+    queryEditors: sqlLab.queryEditors ?? DEFAULT_PROPS.queryEditors,
     queries: sqlLab.queries,
     tabHistory: sqlLab.tabHistory,
     tables: sqlLab.tables,
     defaultDbId: common.conf.SQLLAB_DEFAULT_DBID,
     displayLimit: common.conf.DISPLAY_MAX_ROW,
-    offline: sqlLab.offline,
+    offline: sqlLab.offline ?? DEFAULT_PROPS.offline,
     defaultQueryLimit: common.conf.DEFAULT_SQLLAB_LIMIT,
     maxRow: common.conf.SQL_MAX_ROW,
-    saveQueryWarning: common.conf.SQLLAB_SAVE_WARNING_MESSAGE,
-    scheduleQueryWarning: common.conf.SQLLAB_SCHEDULE_WARNING_MESSAGE,
+    saveQueryWarning:
+      common.conf.SQLLAB_SAVE_WARNING_MESSAGE ?? DEFAULT_PROPS.saveQueryWarning,
+    scheduleQueryWarning:
+      common.conf.SQLLAB_SCHEDULE_WARNING_MESSAGE ??
+      DEFAULT_PROPS.scheduleQueryWarning,
   };
 }
-function mapDispatchToProps(dispatch) {
+
+const mapDispatchToProps = {
+  ...Actions,
+};
+
+function mergeProps(
+  stateProps: ReturnType<typeof mapStateToProps>,
+  dispatchProps: typeof mapDispatchToProps,
+) {
   return {
-    actions: bindActionCreators(Actions, dispatch),
+    ...stateProps,
+    actions: dispatchProps,
   };
 }
 
-export default connect(mapStateToProps, mapDispatchToProps)(TabbedSqlEditors);
+export default connect(
+  mapStateToProps,
+  mapDispatchToProps,
+  mergeProps,
+)(TabbedSqlEditors);
diff --git a/superset-frontend/src/SqlLab/fixtures.ts b/superset-frontend/src/SqlLab/fixtures.ts
index 24d512a1b3..845e2209b5 100644
--- a/superset-frontend/src/SqlLab/fixtures.ts
+++ b/superset-frontend/src/SqlLab/fixtures.ts
@@ -35,7 +35,7 @@ export const alert = { bsStyle: 'danger', msg: 'Ooops', id: 'lksvmcx32' };
 export const table = {
   dbId: 1,
   selectStar: 'SELECT * FROM ab_user',
-  queryEditorId: 'rJ-KP47a',
+  queryEditorId: 'dfsadfs',
   schema: 'superset',
   name: 'ab_user',
   id: 'r11Vgt60',
diff --git a/superset-frontend/src/SqlLab/types.ts b/superset-frontend/src/SqlLab/types.ts
index 6b150c2ba5..cac9ceb5d9 100644
--- a/superset-frontend/src/SqlLab/types.ts
+++ b/superset-frontend/src/SqlLab/types.ts
@@ -16,11 +16,15 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { JsonObject, QueryResponse } from '@superset-ui/core';
-import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
+import { QueryResponse } from '@superset-ui/core';
+import {
+  CommonBootstrapData,
+  UserWithPermissionsAndRoles,
+} from 'src/types/bootstrapTypes';
 import { ToastType } from 'src/components/MessageToasts/types';
 import { DropdownButtonProps } from 'src/components/DropdownButton';
 import { ButtonProps } from 'src/components/Button';
+import type { TableMetaData } from 'src/hooks/apiResources';
 
 export type QueryButtonProps = DropdownButtonProps | ButtonProps;
 
@@ -81,8 +85,10 @@ export interface Table {
   name: string;
   queryEditorId: QueryEditor['id'];
   dataPreviewQueryId: string | null;
-  expanded?: boolean;
+  expanded: boolean;
   initialized?: boolean;
+  inLocalStorage?: boolean;
+  persistData?: TableMetaData;
 }
 
 export type SqlLabRootState = {
@@ -92,7 +98,7 @@ export type SqlLabRootState = {
     databases: Record<string, any>;
     dbConnect: boolean;
     offline: boolean;
-    queries: Record<string, QueryResponse>;
+    queries: Record<string, QueryResponse & { inLocalStorage?: boolean }>;
     queryEditors: QueryEditor[];
     tabHistory: string[]; // default is activeTab ? [activeTab.id.toString()] : []
     tables: Table[];
@@ -105,10 +111,7 @@ export type SqlLabRootState = {
   localStorageUsageInKilobytes: number;
   messageToasts: toastState[];
   user: UserWithPermissionsAndRoles;
-  common: {
-    flash_messages: string[];
-    conf: JsonObject;
-  };
+  common: CommonBootstrapData;
 };
 
 export enum DatasetRadioState {
diff --git a/superset-frontend/src/SqlLab/utils/emptyQueryResults.test.js b/superset-frontend/src/SqlLab/utils/emptyQueryResults.test.ts
similarity index 94%
rename from superset-frontend/src/SqlLab/utils/emptyQueryResults.test.js
rename to superset-frontend/src/SqlLab/utils/emptyQueryResults.test.ts
index f08fccbef7..ca7f60af20 100644
--- a/superset-frontend/src/SqlLab/utils/emptyQueryResults.test.js
+++ b/superset-frontend/src/SqlLab/utils/emptyQueryResults.test.ts
@@ -16,6 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import type { QueryResponse } from '@superset-ui/core';
 import {
   emptyQueryResults,
   clearQueryEditors,
@@ -43,7 +44,7 @@ describe('reduxStateToLocalStorageHelper', () => {
     expect(Date.now() - startDttm).toBeGreaterThan(
       LOCALSTORAGE_MAX_QUERY_AGE_MS,
     );
-    expect(Object.keys(oldQuery.results)).toContain('data');
+    expect(Object.keys(oldQuery.results || {})).toContain('data');
 
     const emptiedQuery = emptyQueryResults(queriesObj);
     expect(emptiedQuery[id].startDttm).toBe(startDttm);
@@ -55,7 +56,7 @@ describe('reduxStateToLocalStorageHelper', () => {
       ...queries[0],
       startDttm: Date.now(),
       results: { data: [{ a: 1 }] },
-    };
+    } as unknown as QueryResponse;
     const largeQuery = {
       ...queries[1],
       startDttm: Date.now(),
@@ -70,7 +71,7 @@ describe('reduxStateToLocalStorageHelper', () => {
           },
         ],
       },
-    };
+    } as unknown as QueryResponse;
     expect(Object.keys(largeQuery.results)).toContain('data');
     const emptiedQuery = emptyQueryResults({
       [reasonableSizeQuery.id]: reasonableSizeQuery,
diff --git a/superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.js b/superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.ts
similarity index 83%
rename from superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.js
rename to superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.ts
index 5b7a31b304..8b7f41f9f7 100644
--- a/superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.js
+++ b/superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.ts
@@ -16,6 +16,9 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import type { QueryResponse } from '@superset-ui/core';
+import type { QueryEditor, SqlLabRootState, Table } from 'src/SqlLab/types';
+import type { ThunkDispatch } from 'redux-thunk';
 import { pick } from 'lodash';
 import { tableApiUtil } from 'src/hooks/apiResources/tables';
 import {
@@ -44,7 +47,7 @@ const PERSISTENT_QUERY_EDITOR_KEYS = new Set([
   'hideLeftBar',
 ]);
 
-function shouldEmptyQueryResults(query) {
+function shouldEmptyQueryResults(query: QueryResponse) {
   const { startDttm, results } = query;
   return (
     Date.now() - startDttm > LOCALSTORAGE_MAX_QUERY_AGE_MS ||
@@ -53,7 +56,7 @@ function shouldEmptyQueryResults(query) {
   );
 }
 
-export function emptyTablePersistData(tables) {
+export function emptyTablePersistData(tables: Table[]) {
   return tables
     .map(table =>
       pick(table, [
@@ -68,7 +71,9 @@ export function emptyTablePersistData(tables) {
     .filter(({ queryEditorId }) => Boolean(queryEditorId));
 }
 
-export function emptyQueryResults(queries) {
+export function emptyQueryResults(
+  queries: SqlLabRootState['sqlLab']['queries'],
+) {
   return Object.keys(queries).reduce((accu, key) => {
     const { results } = queries[key];
     const query = {
@@ -84,7 +89,7 @@ export function emptyQueryResults(queries) {
   }, {});
 }
 
-export function clearQueryEditors(queryEditors) {
+export function clearQueryEditors(queryEditors: QueryEditor[]) {
   return queryEditors.map(editor =>
     // only return selected keys
     Object.keys(editor)
@@ -99,7 +104,10 @@ export function clearQueryEditors(queryEditors) {
   );
 }
 
-export function rehydratePersistedState(dispatch, state) {
+export function rehydratePersistedState(
+  dispatch: ThunkDispatch<SqlLabRootState, unknown, any>,
+  state: SqlLabRootState,
+) {
   // Rehydrate server side persisted table metadata
   state.sqlLab.tables.forEach(({ name: table, schema, dbId, persistData }) => {
     if (dbId && schema && table && persistData?.columns) {
diff --git a/superset-frontend/src/components/EmptyState/index.tsx b/superset-frontend/src/components/EmptyState/index.tsx
index 95c454b0ae..9a3e22cf1b 100644
--- a/superset-frontend/src/components/EmptyState/index.tsx
+++ b/superset-frontend/src/components/EmptyState/index.tsx
@@ -29,7 +29,7 @@ export enum EmptyStateSize {
 }
 
 export interface EmptyStateSmallProps {
-  title: ReactNode;
+  title?: ReactNode;
   description?: ReactNode;
   image?: ReactNode;
 }
diff --git a/superset-frontend/src/types/bootstrapTypes.ts b/superset-frontend/src/types/bootstrapTypes.ts
index 80570f5d22..332acf0e82 100644
--- a/superset-frontend/src/types/bootstrapTypes.ts
+++ b/superset-frontend/src/types/bootstrapTypes.ts
@@ -160,6 +160,7 @@ export interface BootstrapData {
   embedded?: {
     dashboard_id: string;
   };
+  requested_query?: JsonObject;
 }
 
 export function isUser(user: any): user is User {