You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ta...@apache.org on 2020/09/28 18:16:22 UTC

[incubator-superset] branch master updated: refactor: table selector on dataset editor (#10914)

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

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


The following commit(s) were added to refs/heads/master by this push:
     new e337355  refactor: table selector on dataset editor (#10914)
e337355 is described below

commit e337355162f3f5716f1cad79a6b51a1359fecac8
Author: Lily Kuang <li...@preset.io>
AuthorDate: Mon Sep 28 11:16:03 2020 -0700

    refactor: table selector on dataset editor (#10914)
    
    Co-authored-by: Maxime Beauchemin <ma...@gmail.com>
---
 .../cypress/integration/explore/control.test.ts    |   2 +
 .../javascripts/components/TableSelector_spec.jsx  | 430 ++++++++++----------
 .../datasource/DatasourceModal_spec.jsx            |  10 +-
 .../src/SqlLab/components/SqlEditorLeftBar.jsx     |  11 +-
 .../src/components/DatabaseSelector.tsx            | 283 +++++++++++++
 superset-frontend/src/components/TableSelector.jsx | 446 ---------------------
 superset-frontend/src/components/TableSelector.tsx | 379 +++++++++++++++++
 .../src/datasource/DatasourceEditor.jsx            | 209 +++++++---
 .../views/CRUD/data/dataset/AddDatasetModal.tsx    |   9 +-
 superset-frontend/stylesheets/superset.less        |   4 +
 tests/datasets/api_tests.py                        |   4 -
 11 files changed, 1050 insertions(+), 737 deletions(-)

diff --git a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts
index 57908b9..a25a28d 100644
--- a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts
+++ b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts
@@ -48,6 +48,7 @@ describe('Datasource control', () => {
     });
 
     // create new metric
+    cy.get('a[role="tab"]').contains('Metrics').click();
     cy.get('button').contains('Add Item', { timeout: 10000 }).click();
     cy.get('input[value="<new metric>"]').click();
     cy.get('input[value="<new metric>"]')
@@ -64,6 +65,7 @@ describe('Datasource control', () => {
     // delete metric
     cy.get('#datasource_menu').click();
     cy.get('a').contains('Edit Datasource').click();
+    cy.get('a[role="tab"]').contains('Metrics').click();
     cy.get(`input[value="${newMetricName}"]`)
       .closest('tr')
       .find('.fa-trash')
diff --git a/superset-frontend/spec/javascripts/components/TableSelector_spec.jsx b/superset-frontend/spec/javascripts/components/TableSelector_spec.jsx
index b78c214..96b1d2e 100644
--- a/superset-frontend/spec/javascripts/components/TableSelector_spec.jsx
+++ b/superset-frontend/spec/javascripts/components/TableSelector_spec.jsx
@@ -18,267 +18,273 @@
  */
 import React from 'react';
 import configureStore from 'redux-mock-store';
-import { shallow } from 'enzyme';
+import { mount } from 'enzyme';
+import { act } from 'react-dom/test-utils';
 import sinon from 'sinon';
 import fetchMock from 'fetch-mock';
 import thunk from 'redux-thunk';
+import { supersetTheme, ThemeProvider } from '@superset-ui/core';
 
+import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
+
+import DatabaseSelector from 'src/components/DatabaseSelector';
 import TableSelector from 'src/components/TableSelector';
 import { initialState, tables } from '../sqllab/fixtures';
 
-describe('TableSelector', () => {
-  let mockedProps;
-  const middlewares = [thunk];
-  const mockStore = configureStore(middlewares);
-  const store = mockStore(initialState);
-  let wrapper;
-  let inst;
+const mockStore = configureStore([thunk]);
+const store = mockStore(initialState);
 
-  beforeEach(() => {
-    mockedProps = {
-      dbId: 1,
-      schema: 'main',
-      onSchemaChange: sinon.stub(),
-      onDbChange: sinon.stub(),
-      getDbList: sinon.stub(),
-      onTableChange: sinon.stub(),
-      onChange: sinon.stub(),
-      tableNameSticky: true,
-      tableName: '',
-      database: { id: 1, database_name: 'main' },
-      horizontal: false,
-      sqlLabMode: true,
-      clearable: false,
-      handleError: sinon.stub(),
-    };
-    wrapper = shallow(<TableSelector {...mockedProps} />, {
-      context: { store },
-    });
-    inst = wrapper.instance();
-  });
+const FETCH_SCHEMAS_ENDPOINT = 'glob:*/api/v1/database/*/schemas/*';
+const GET_TABLE_ENDPOINT = 'glob:*/superset/tables/1/*/*';
+const GET_TABLE_NAMES_ENDPOINT = 'glob:*/superset/tables/1/main/*';
 
-  it('is valid', () => {
-    expect(React.isValidElement(<TableSelector {...mockedProps} />)).toBe(true);
-  });
+const mockedProps = {
+  clearable: false,
+  database: { id: 1, database_name: 'main' },
+  dbId: 1,
+  formMode: false,
+  getDbList: sinon.stub(),
+  handleError: sinon.stub(),
+  horizontal: false,
+  onChange: sinon.stub(),
+  onDbChange: sinon.stub(),
+  onSchemaChange: sinon.stub(),
+  onTableChange: sinon.stub(),
+  sqlLabMode: true,
+  tableName: '',
+  tableNameSticky: true,
+};
 
-  describe('onDatabaseChange', () => {
-    it('should fetch schemas', () => {
-      sinon.stub(inst, 'fetchSchemas');
-      inst.onDatabaseChange({ id: 1 });
-      expect(inst.fetchSchemas.getCall(0).args[0]).toBe(1);
-      inst.fetchSchemas.restore();
-    });
-    it('should clear tableOptions', () => {
-      inst.onDatabaseChange();
-      expect(wrapper.state().tableOptions).toEqual([]);
-    });
-  });
-
-  describe('getTableNamesBySubStr', () => {
-    const GET_TABLE_NAMES_GLOB = 'glob:*/superset/tables/1/main/*';
+const schemaOptions = {
+  result: ['main', 'erf', 'superset'],
+};
+const selectedSchema = { label: 'main', title: 'main', value: 'main' };
+const selectedTable = {
+  label: 'birth_names',
+  schema: 'main',
+  title: 'birth_names',
+  value: 'birth_names',
+  type: undefined,
+};
 
-    afterEach(fetchMock.resetHistory);
-    afterAll(fetchMock.reset);
-
-    it('should handle empty', () =>
-      inst.getTableNamesBySubStr('').then(data => {
-        expect(data).toEqual({ options: [] });
-        return Promise.resolve();
-      }));
+async function mountAndWait(props = mockedProps) {
+  const mounted = mount(<TableSelector {...props} />, {
+    context: { store },
+    wrappingComponent: ThemeProvider,
+    wrappingComponentProps: { theme: supersetTheme },
+  });
+  await waitForComponentToPaint(mounted);
 
-    it('should handle table name', () => {
-      fetchMock.get(GET_TABLE_NAMES_GLOB, tables, { overwriteRoutes: true });
+  return mounted;
+}
 
-      return wrapper
-        .instance()
-        .getTableNamesBySubStr('my table')
-        .then(data => {
-          expect(fetchMock.calls(GET_TABLE_NAMES_GLOB)).toHaveLength(1);
-          expect(data).toEqual({
-            options: [
-              {
-                value: 'birth_names',
-                schema: 'main',
-                label: 'birth_names',
-                title: 'birth_names',
-              },
-              {
-                value: 'energy_usage',
-                schema: 'main',
-                label: 'energy_usage',
-                title: 'energy_usage',
-              },
-              {
-                value: 'wb_health_population',
-                schema: 'main',
-                label: 'wb_health_population',
-                title: 'wb_health_population',
-              },
-            ],
-          });
-          return Promise.resolve();
-        });
-    });
+describe('TableSelector', () => {
+  let wrapper;
 
-    it('should escape schema and table names', () => {
-      const GET_TABLE_GLOB = 'glob:*/superset/tables/1/*/*';
-      wrapper.setProps({ schema: 'slashed/schema' });
-      fetchMock.get(GET_TABLE_GLOB, tables, { overwriteRoutes: true });
+  beforeEach(async () => {
+    fetchMock.reset();
+    wrapper = await mountAndWait();
+  });
 
-      return wrapper
-        .instance()
-        .getTableNamesBySubStr('slashed/table')
-        .then(() => {
-          expect(fetchMock.lastUrl(GET_TABLE_GLOB)).toContain(
-            '/slashed%252Fschema/slashed%252Ftable',
-          );
-          return Promise.resolve();
-        });
-    });
+  it('renders', () => {
+    expect(wrapper.find(TableSelector)).toExist();
+    expect(wrapper.find(DatabaseSelector)).toExist();
   });
 
-  describe('fetchTables', () => {
-    const FETCH_TABLES_GLOB = 'glob:*/superset/tables/1/main/*/*/';
+  describe('change database', () => {
     afterEach(fetchMock.resetHistory);
     afterAll(fetchMock.reset);
 
-    it('should clear table options', () => {
-      inst.fetchTables(true);
-      expect(wrapper.state().tableOptions).toEqual([]);
+    it('should fetch schemas', async () => {
+      fetchMock.get(FETCH_SCHEMAS_ENDPOINT, { overwriteRoutes: true });
+      act(() => {
+        wrapper.find('[data-test="select-database"]').first().props().onChange({
+          id: 1,
+          database_name: 'main',
+        });
+      });
+      await waitForComponentToPaint(wrapper);
+      expect(fetchMock.calls(FETCH_SCHEMAS_ENDPOINT)).toHaveLength(1);
     });
 
-    it('should fetch table options', () => {
-      fetchMock.get(FETCH_TABLES_GLOB, tables, { overwriteRoutes: true });
-      return inst.fetchTables(true, 'birth_names').then(() => {
-        expect(wrapper.state().tableOptions).toHaveLength(3);
-        expect(wrapper.state().tableOptions).toEqual([
-          {
-            value: 'birth_names',
-            schema: 'main',
-            label: 'birth_names',
-            title: 'birth_names',
-          },
-          {
-            value: 'energy_usage',
-            schema: 'main',
-            label: 'energy_usage',
-            title: 'energy_usage',
-          },
-          {
-            value: 'wb_health_population',
-            schema: 'main',
-            label: 'wb_health_population',
-            title: 'wb_health_population',
-          },
-        ]);
-        return Promise.resolve();
+    it('should fetch schema options', async () => {
+      fetchMock.get(FETCH_SCHEMAS_ENDPOINT, schemaOptions, {
+        overwriteRoutes: true,
       });
-    });
+      act(() => {
+        wrapper.find('[data-test="select-database"]').first().props().onChange({
+          id: 1,
+          database_name: 'main',
+        });
+      });
+      await waitForComponentToPaint(wrapper);
+      wrapper.update();
+      expect(fetchMock.calls(FETCH_SCHEMAS_ENDPOINT)).toHaveLength(1);
 
-    // Test needs to be fixed: Github issue #7768
-    it.skip('should dispatch a danger toast on error', () => {
-      fetchMock.get(
-        FETCH_TABLES_GLOB,
-        { throws: 'error' },
-        { overwriteRoutes: true },
-      );
+      expect(
+        wrapper.find('[name="select-schema"]').first().props().options,
+      ).toEqual([
+        { value: 'main', label: 'main', title: 'main' },
+        { value: 'erf', label: 'erf', title: 'erf' },
+        { value: 'superset', label: 'superset', title: 'superset' },
+      ]);
+    });
 
-      wrapper
-        .instance()
-        .fetchTables(true, 'birth_names')
-        .then(() => {
-          expect(wrapper.state().tableOptions).toEqual([]);
-          expect(wrapper.state().tableOptions).toHaveLength(0);
-          expect(mockedProps.handleError.callCount).toBe(1);
-          return Promise.resolve();
+    it('should clear table options', async () => {
+      act(() => {
+        wrapper.find('[data-test="select-database"]').first().props().onChange({
+          id: 1,
+          database_name: 'main',
         });
+      });
+      await waitForComponentToPaint(wrapper);
+      const props = wrapper.find('[name="async-select-table"]').first().props();
+      expect(props.isDisabled).toBe(true);
+      expect(props.value).toEqual(undefined);
     });
   });
 
-  describe('fetchSchemas', () => {
-    const FETCH_SCHEMAS_GLOB = 'glob:*/api/v1/database/*/schemas/?q=(force:!*)';
-    afterEach(fetchMock.resetHistory);
-    afterAll(fetchMock.reset);
-
-    it('should fetch schema options', () => {
-      const schemaOptions = {
-        result: ['main', 'erf', 'superset'],
-      };
-      fetchMock.get(FETCH_SCHEMAS_GLOB, schemaOptions, {
+  describe('change schema', () => {
+    beforeEach(async () => {
+      fetchMock.get(FETCH_SCHEMAS_ENDPOINT, schemaOptions, {
         overwriteRoutes: true,
       });
+    });
+
+    afterEach(fetchMock.resetHistory);
+    afterAll(fetchMock.reset);
 
-      return wrapper
-        .instance()
-        .fetchSchemas(1)
-        .then(() => {
-          expect(fetchMock.calls(FETCH_SCHEMAS_GLOB)).toHaveLength(1);
-          expect(wrapper.state().schemaOptions).toHaveLength(3);
+    it('should fetch table', async () => {
+      fetchMock.get(GET_TABLE_NAMES_ENDPOINT, { overwriteRoutes: true });
+      act(() => {
+        wrapper.find('[data-test="select-database"]').first().props().onChange({
+          id: 1,
+          database_name: 'main',
         });
+      });
+      await waitForComponentToPaint(wrapper);
+      act(() => {
+        wrapper
+          .find('[name="select-schema"]')
+          .first()
+          .props()
+          .onChange(selectedSchema);
+      });
+      await waitForComponentToPaint(wrapper);
+      expect(fetchMock.calls(GET_TABLE_NAMES_ENDPOINT)).toHaveLength(1);
     });
 
-    // Test needs to be fixed: Github issue #7768
-    it.skip('should dispatch a danger toast on error', () => {
-      const handleErrors = sinon.stub();
-      expect(handleErrors.callCount).toBe(0);
-      wrapper.setProps({ handleErrors });
-      fetchMock.get(
-        FETCH_SCHEMAS_GLOB,
-        { throws: new Error('Bad kitty') },
-        { overwriteRoutes: true },
-      );
-      wrapper
-        .instance()
-        .fetchSchemas(123)
-        .then(() => {
-          expect(wrapper.state().schemaOptions).toEqual([]);
-          expect(handleErrors.callCount).toBe(1);
+    it('should fetch table options', async () => {
+      fetchMock.get(GET_TABLE_NAMES_ENDPOINT, tables, {
+        overwriteRoutes: true,
+      });
+      act(() => {
+        wrapper.find('[data-test="select-database"]').first().props().onChange({
+          id: 1,
+          database_name: 'main',
         });
+      });
+      await waitForComponentToPaint(wrapper);
+      act(() => {
+        wrapper
+          .find('[name="select-schema"]')
+          .first()
+          .props()
+          .onChange(selectedSchema);
+      });
+      await waitForComponentToPaint(wrapper);
+      expect(
+        wrapper.find('[name="select-schema"]').first().props().value[0],
+      ).toEqual(selectedSchema);
+      expect(fetchMock.calls(GET_TABLE_NAMES_ENDPOINT)).toHaveLength(1);
+      const { options } = wrapper.find('[name="select-table"]').first().props();
+      expect({ options }).toEqual(tables);
     });
   });
 
-  describe('changeTable', () => {
-    beforeEach(() => {
-      sinon.stub(wrapper.instance(), 'fetchTables');
-    });
-
-    afterEach(() => {
-      wrapper.instance().fetchTables.restore();
+  describe('change table', () => {
+    beforeEach(async () => {
+      fetchMock.get(GET_TABLE_NAMES_ENDPOINT, tables, {
+        overwriteRoutes: true,
+      });
     });
 
-    it('test 1', () => {
-      wrapper.instance().changeTable({
-        value: 'birth_names',
-        schema: 'main',
-        label: 'birth_names',
-        title: 'birth_names',
+    it('should change table value', async () => {
+      act(() => {
+        wrapper
+          .find('[name="select-schema"]')
+          .first()
+          .props()
+          .onChange(selectedSchema);
+      });
+      await waitForComponentToPaint(wrapper);
+      act(() => {
+        wrapper
+          .find('[name="select-table"]')
+          .first()
+          .props()
+          .onChange(selectedTable);
       });
-      expect(wrapper.state().tableName).toBe('birth_names');
+      await waitForComponentToPaint(wrapper);
+      expect(
+        wrapper.find('[name="select-table"]').first().props().value,
+      ).toEqual('birth_names');
     });
 
-    it('should call onTableChange with schema from table object', () => {
-      wrapper.setProps({ schema: null });
-      wrapper.instance().changeTable({
-        value: 'my_table',
-        schema: 'other_schema',
-        label: 'other_schema.my_table',
-        title: 'other_schema.my_table',
+    it('should call onTableChange with schema from table object', async () => {
+      act(() => {
+        wrapper
+          .find('[name="select-schema"]')
+          .first()
+          .props()
+          .onChange(selectedSchema);
       });
-      expect(mockedProps.onTableChange.getCall(0).args[0]).toBe('my_table');
-      expect(mockedProps.onTableChange.getCall(0).args[1]).toBe('other_schema');
+      await waitForComponentToPaint(wrapper);
+      act(() => {
+        wrapper
+          .find('[name="select-table"]')
+          .first()
+          .props()
+          .onChange(selectedTable);
+      });
+      await waitForComponentToPaint(wrapper);
+      expect(mockedProps.onTableChange.getCall(0).args[0]).toBe('birth_names');
+      expect(mockedProps.onTableChange.getCall(0).args[1]).toBe('main');
     });
   });
 
-  it('changeSchema', () => {
-    sinon.stub(wrapper.instance(), 'fetchTables');
+  describe('getTableNamesBySubStr', () => {
+    afterEach(fetchMock.resetHistory);
+    afterAll(fetchMock.reset);
 
-    wrapper.instance().changeSchema({ label: 'main', value: 'main' });
-    expect(wrapper.instance().fetchTables.callCount).toBe(1);
-    expect(mockedProps.onChange.callCount).toBe(1);
-    wrapper.instance().changeSchema();
-    expect(wrapper.instance().fetchTables.callCount).toBe(2);
-    expect(mockedProps.onChange.callCount).toBe(2);
+    it('should handle empty', async () => {
+      act(() => {
+        wrapper
+          .find('[name="async-select-table"]')
+          .first()
+          .props()
+          .loadOptions();
+      });
+      await waitForComponentToPaint(wrapper);
+      const props = wrapper.find('[name="async-select-table"]').first().props();
+      expect(props.isDisabled).toBe(true);
+      expect(props.value).toEqual('');
+    });
 
-    wrapper.instance().fetchTables.restore();
+    it('should handle table name', async () => {
+      wrapper.setProps({ schema: 'main' });
+      fetchMock.get(GET_TABLE_ENDPOINT, tables, {
+        overwriteRoutes: true,
+      });
+      act(() => {
+        wrapper
+          .find('[name="async-select-table"]')
+          .first()
+          .props()
+          .loadOptions();
+      });
+      await waitForComponentToPaint(wrapper);
+      expect(fetchMock.calls(GET_TABLE_ENDPOINT)).toHaveLength(1);
+    });
   });
 });
diff --git a/superset-frontend/spec/javascripts/datasource/DatasourceModal_spec.jsx b/superset-frontend/spec/javascripts/datasource/DatasourceModal_spec.jsx
index dde67f9..aa5171f 100644
--- a/superset-frontend/spec/javascripts/datasource/DatasourceModal_spec.jsx
+++ b/superset-frontend/spec/javascripts/datasource/DatasourceModal_spec.jsx
@@ -60,12 +60,10 @@ async function mountAndWait(props = mockedProps) {
 }
 
 describe('DatasourceModal', () => {
-  fetchMock.post(SAVE_ENDPOINT, SAVE_PAYLOAD);
-  const callsP = fetchMock.put(SAVE_ENDPOINT, SAVE_PAYLOAD);
-
   let wrapper;
 
   beforeEach(async () => {
+    fetchMock.reset();
     wrapper = await mountAndWait();
   });
 
@@ -82,6 +80,7 @@ describe('DatasourceModal', () => {
   });
 
   it('saves on confirm', async () => {
+    const callsP = fetchMock.post(SAVE_ENDPOINT, SAVE_PAYLOAD);
     act(() => {
       wrapper
         .find('button[data-test="datasource-modal-save"]')
@@ -94,6 +93,9 @@ describe('DatasourceModal', () => {
       okButton.simulate('click');
     });
     await waitForComponentToPaint(wrapper);
-    expect(callsP._calls).toHaveLength(2); /* eslint no-underscore-dangle: 0 */
+    const expected = ['http://localhost/datasource/save/'];
+    expect(callsP._calls.map(call => call[0])).toEqual(
+      expected,
+    ); /* eslint no-underscore-dangle: 0 */
   });
 });
diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar.jsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar.jsx
index 552ba4c..66539b2 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar.jsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar.jsx
@@ -120,17 +120,18 @@ export default class SqlEditorLeftBar extends React.PureComponent {
     return (
       <div className="SqlEditorLeftBar">
         <TableSelector
+          database={this.props.database}
           dbId={qe.dbId}
-          schema={qe.schema}
+          getDbList={this.getDbList}
+          handleError={this.props.actions.addDangerToast}
           onDbChange={this.onDbChange}
           onSchemaChange={this.onSchemaChange}
           onSchemasLoad={this.onSchemasLoad}
-          onTablesLoad={this.onTablesLoad}
-          getDbList={this.getDbList}
           onTableChange={this.onTableChange}
+          onTablesLoad={this.onTablesLoad}
+          schema={qe.schema}
+          sqlLabMode
           tableNameSticky={false}
-          database={this.props.database}
-          handleError={this.props.actions.addDangerToast}
         />
         <div className="divider" />
         <div className="scrollbar-container">
diff --git a/superset-frontend/src/components/DatabaseSelector.tsx b/superset-frontend/src/components/DatabaseSelector.tsx
new file mode 100644
index 0000000..f426e1a
--- /dev/null
+++ b/superset-frontend/src/components/DatabaseSelector.tsx
@@ -0,0 +1,283 @@
+/**
+ * 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, { ReactNode, useEffect, useState } from 'react';
+import { styled, SupersetClient, t } from '@superset-ui/core';
+import rison from 'rison';
+import { Select } from 'src/components/Select';
+import Label from 'src/components/Label';
+
+import SupersetAsyncSelect from './AsyncSelect';
+import RefreshLabel from './RefreshLabel';
+
+const FieldTitle = styled.p`
+  color: ${({ theme }) => theme.colors.secondary.light2};
+  font-size: ${({ theme }) => theme.typography.sizes.s}px;
+  margin: 20px 0 10px 0;
+  text-transform: uppercase;
+`;
+
+const DatabaseSelectorWrapper = styled.div`
+  .fa-refresh {
+    padding-left: 9px;
+  }
+
+  .refresh-col {
+    display: flex;
+    align-items: center;
+    width: 30px;
+  }
+
+  .section {
+    padding-bottom: 5px;
+    display: flex;
+    flex-direction: row;
+  }
+
+  .select {
+    flex-grow: 1;
+  }
+`;
+
+interface DatabaseSelectorProps {
+  dbId: number;
+  formMode?: boolean;
+  getDbList?: (arg0: any) => {};
+  getTableList?: (dbId: number, schema: string, force: boolean) => {};
+  handleError: (msg: string) => void;
+  isDatabaseSelectEnabled?: boolean;
+  onDbChange?: (db: any) => void;
+  onSchemaChange?: (arg0?: any) => {};
+  onSchemasLoad?: (schemas: Array<object>) => void;
+  schema?: string;
+  sqlLabMode?: boolean;
+  onChange?: ({
+    dbId,
+    schema,
+  }: {
+    dbId: number;
+    schema?: string;
+    tableName?: string;
+  }) => void;
+}
+
+export default function DatabaseSelector({
+  dbId,
+  formMode = false,
+  getDbList,
+  getTableList,
+  handleError,
+  isDatabaseSelectEnabled = true,
+  onChange,
+  onDbChange,
+  onSchemaChange,
+  onSchemasLoad,
+  schema,
+  sqlLabMode = false,
+}: DatabaseSelectorProps) {
+  const [currentDbId, setCurrentDbId] = useState(dbId);
+  const [currentSchema, setCurrentSchema] = useState<string | undefined>(
+    schema,
+  );
+  const [schemaLoading, setSchemaLoading] = useState(false);
+  const [schemaOptions, setSchemaOptions] = useState([]);
+
+  function fetchSchemas(databaseId: number, forceRefresh = false) {
+    const actualDbId = databaseId || dbId;
+    if (actualDbId) {
+      setSchemaLoading(true);
+      const queryParams = rison.encode({
+        force: Boolean(forceRefresh),
+      });
+      const endpoint = `/api/v1/database/${actualDbId}/schemas/?q=${queryParams}`;
+      return SupersetClient.get({ endpoint })
+        .then(({ json }) => {
+          const options = json.result.map((s: string) => ({
+            value: s,
+            label: s,
+            title: s,
+          }));
+          setSchemaOptions(options);
+          setSchemaLoading(false);
+          if (onSchemasLoad) {
+            onSchemasLoad(options);
+          }
+        })
+        .catch(() => {
+          setSchemaOptions([]);
+          setSchemaLoading(false);
+          handleError(t('Error while fetching schema list'));
+        });
+    }
+    return Promise.resolve();
+  }
+
+  useEffect(() => {
+    if (currentDbId) {
+      fetchSchemas(currentDbId);
+    }
+  }, [currentDbId]);
+
+  function onSelectChange({ dbId, schema }: { dbId: number; schema?: string }) {
+    setCurrentDbId(dbId);
+    setCurrentSchema(schema);
+    if (onChange) {
+      onChange({ dbId, schema, tableName: undefined });
+    }
+  }
+
+  function dbMutator(data: any) {
+    if (getDbList) {
+      getDbList(data.result);
+    }
+    if (data.result.length === 0) {
+      handleError(t("It seems you don't have access to any database"));
+    }
+    return data.result.map((row: any) => ({
+      ...row,
+      // label is used for the typeahead
+      label: `${row.backend} ${row.database_name}`,
+    }));
+  }
+
+  function changeDataBase(db: any, force = false) {
+    const dbId = db ? db.id : null;
+    setSchemaOptions([]);
+    if (onSchemaChange) {
+      onSchemaChange(null);
+    }
+    if (onDbChange) {
+      onDbChange(db);
+    }
+    fetchSchemas(dbId, force);
+    onSelectChange({ dbId, schema: undefined });
+  }
+
+  function changeSchema(schemaOpt: any, force = false) {
+    const schema = schemaOpt ? schemaOpt.value : null;
+    if (onSchemaChange) {
+      onSchemaChange(schema);
+    }
+    setCurrentSchema(schema);
+    onSelectChange({ dbId: currentDbId, schema });
+    if (getTableList) {
+      getTableList(currentDbId, schema, force);
+    }
+  }
+
+  function renderDatabaseOption(db: any) {
+    return (
+      <span title={db.database_name}>
+        <Label bsStyle="default">{db.backend}</Label>
+        {db.database_name}
+      </span>
+    );
+  }
+
+  function renderSelectRow(select: ReactNode, refreshBtn: ReactNode) {
+    return (
+      <div className="section">
+        <span className="select">{select}</span>
+        <span className="refresh-col">{refreshBtn}</span>
+      </div>
+    );
+  }
+
+  function renderDatabaseSelect() {
+    const queryParams = rison.encode({
+      order_columns: 'database_name',
+      order_direction: 'asc',
+      page: 0,
+      page_size: -1,
+      ...(formMode || !sqlLabMode
+        ? {}
+        : {
+            filters: [
+              {
+                col: 'expose_in_sqllab',
+                opr: 'eq',
+                value: true,
+              },
+            ],
+          }),
+    });
+
+    return renderSelectRow(
+      <SupersetAsyncSelect
+        data-test="select-database"
+        dataEndpoint={`/api/v1/database/?q=${queryParams}`}
+        onChange={(db: any) => changeDataBase(db)}
+        onAsyncError={() =>
+          handleError(t('Error while fetching database list'))
+        }
+        clearable={false}
+        value={currentDbId}
+        valueKey="id"
+        valueRenderer={(db: any) => (
+          <div>
+            <span className="text-muted m-r-5">{t('Database:')}</span>
+            {renderDatabaseOption(db)}
+          </div>
+        )}
+        optionRenderer={renderDatabaseOption}
+        mutator={dbMutator}
+        placeholder={t('Select a database')}
+        autoSelect
+        isDisabled={!isDatabaseSelectEnabled}
+      />,
+      null,
+    );
+  }
+
+  function renderSchemaSelect() {
+    const value = schemaOptions.filter(({ value }) => currentSchema === value);
+    const refresh = !formMode && (
+      <RefreshLabel
+        onClick={() => changeDataBase({ id: dbId }, true)}
+        tooltipContent={t('Force refresh schema list')}
+      />
+    );
+
+    return renderSelectRow(
+      <Select
+        name="select-schema"
+        placeholder={t('Select a schema (%s)', schemaOptions.length)}
+        options={schemaOptions}
+        value={value}
+        valueRenderer={o => (
+          <div>
+            <span className="text-muted">{t('Schema:')}</span> {o.label}
+          </div>
+        )}
+        isLoading={schemaLoading}
+        autosize={false}
+        onChange={item => changeSchema(item)}
+      />,
+      refresh,
+    );
+  }
+
+  return (
+    <DatabaseSelectorWrapper>
+      {formMode && <FieldTitle>{t('datasource')}</FieldTitle>}
+      {renderDatabaseSelect()}
+      {formMode && <FieldTitle>{t('schema')}</FieldTitle>}
+      {renderSchemaSelect()}
+    </DatabaseSelectorWrapper>
+  );
+}
diff --git a/superset-frontend/src/components/TableSelector.jsx b/superset-frontend/src/components/TableSelector.jsx
deleted file mode 100644
index 395c6be..0000000
--- a/superset-frontend/src/components/TableSelector.jsx
+++ /dev/null
@@ -1,446 +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 { styled, SupersetClient, t } from '@superset-ui/core';
-import PropTypes from 'prop-types';
-import rison from 'rison';
-import { AsyncSelect, CreatableSelect, Select } from 'src/components/Select';
-
-import Label from 'src/components/Label';
-import FormLabel from 'src/components/FormLabel';
-
-import SupersetAsyncSelect from './AsyncSelect';
-import RefreshLabel from './RefreshLabel';
-import './TableSelector.less';
-
-const FieldTitle = styled.p`
-  color: ${({ theme }) => theme.colors.secondary.light2};
-  font-size: ${({ theme }) => theme.typography.sizes.s}px;
-  margin: 20px 0 10px 0;
-  text-transform: uppercase;
-`;
-
-const propTypes = {
-  dbId: PropTypes.number.isRequired,
-  schema: PropTypes.string,
-  onSchemaChange: PropTypes.func,
-  onDbChange: PropTypes.func,
-  onSchemasLoad: PropTypes.func,
-  onTablesLoad: PropTypes.func,
-  getDbList: PropTypes.func,
-  onTableChange: PropTypes.func,
-  tableNameSticky: PropTypes.bool,
-  tableName: PropTypes.string,
-  database: PropTypes.object,
-  sqlLabMode: PropTypes.bool,
-  formMode: PropTypes.bool,
-  onChange: PropTypes.func,
-  clearable: PropTypes.bool,
-  handleError: PropTypes.func.isRequired,
-  isDatabaseSelectEnabled: PropTypes.bool,
-};
-
-const defaultProps = {
-  onDbChange: () => {},
-  onSchemaChange: () => {},
-  onSchemasLoad: () => {},
-  onTablesLoad: () => {},
-  getDbList: () => {},
-  onTableChange: () => {},
-  onChange: () => {},
-  tableNameSticky: true,
-  sqlLabMode: true,
-  formMode: false,
-  clearable: true,
-  isDatabaseSelectEnabled: true,
-};
-
-export default class TableSelector extends React.PureComponent {
-  constructor(props) {
-    super(props);
-    this.state = {
-      schemaLoading: false,
-      schemaOptions: [],
-      tableLoading: false,
-      tableOptions: [],
-      dbId: props.dbId,
-      schema: props.schema,
-      tableName: props.tableName,
-    };
-    this.onDatabaseChange = this.onDatabaseChange.bind(this);
-    this.onSchemaChange = this.onSchemaChange.bind(this);
-    this.changeTable = this.changeTable.bind(this);
-    this.dbMutator = this.dbMutator.bind(this);
-    this.getTableNamesBySubStr = this.getTableNamesBySubStr.bind(this);
-    this.onChange = this.onChange.bind(this);
-  }
-
-  componentDidMount() {
-    if (this.state.dbId) {
-      this.fetchSchemas(this.state.dbId);
-      this.fetchTables();
-    }
-  }
-
-  onChange() {
-    this.props.onChange({
-      dbId: this.state.dbId,
-      schema: this.state.schema,
-      tableName: this.state.tableName,
-    });
-  }
-
-  onDatabaseChange(db, force) {
-    return this.changeDataBase(db, force);
-  }
-
-  onSchemaChange(schemaOpt) {
-    return this.changeSchema(schemaOpt);
-  }
-
-  getTableNamesBySubStr(substr = 'undefined') {
-    if (!this.props.dbId || !substr) {
-      const options = [];
-      return Promise.resolve({ options });
-    }
-    const encodedSchema = encodeURIComponent(this.props.schema);
-    const encodedSubstr = encodeURIComponent(substr);
-    return SupersetClient.get({
-      endpoint: encodeURI(
-        `/superset/tables/${this.props.dbId}/${encodedSchema}/${encodedSubstr}`,
-      ),
-    }).then(({ json }) => {
-      const options = json.options.map(o => ({
-        value: o.value,
-        schema: o.schema,
-        label: o.label,
-        title: o.title,
-        type: o.type,
-      }));
-      return { options };
-    });
-  }
-
-  dbMutator(data) {
-    this.props.getDbList(data.result);
-    if (data.result.length === 0) {
-      this.props.handleError(
-        t("It seems you don't have access to any database"),
-      );
-    }
-    return data.result.map(row => ({
-      ...row,
-      // label is used for the typeahead
-      label: `${row.backend} ${row.database_name}`,
-    }));
-  }
-
-  fetchTables(forceRefresh = false, substr = 'undefined') {
-    const { dbId, schema } = this.state;
-    const encodedSchema = encodeURIComponent(schema);
-    const encodedSubstr = encodeURIComponent(substr);
-    if (dbId && schema) {
-      this.setState(() => ({ tableLoading: true, tableOptions: [] }));
-      const endpoint = encodeURI(
-        `/superset/tables/${dbId}/${encodedSchema}/${encodedSubstr}/${!!forceRefresh}/`,
-      );
-      return SupersetClient.get({ endpoint })
-        .then(({ json }) => {
-          const options = json.options.map(o => ({
-            value: o.value,
-            schema: o.schema,
-            label: o.label,
-            title: o.title,
-            type: o.type,
-          }));
-          this.setState(() => ({
-            tableLoading: false,
-            tableOptions: options,
-          }));
-          this.props.onTablesLoad(json.options);
-        })
-        .catch(() => {
-          this.setState(() => ({ tableLoading: false, tableOptions: [] }));
-          this.props.handleError(t('Error while fetching table list'));
-        });
-    }
-    this.setState(() => ({ tableLoading: false, tableOptions: [] }));
-    return Promise.resolve();
-  }
-
-  fetchSchemas(dbId, forceRefresh = false) {
-    const actualDbId = dbId || this.props.dbId;
-    if (actualDbId) {
-      this.setState({ schemaLoading: true });
-      const queryParams = rison.encode({
-        force: Boolean(forceRefresh),
-      });
-      const endpoint = `/api/v1/database/${actualDbId}/schemas/?q=${queryParams}`;
-      return SupersetClient.get({ endpoint })
-        .then(({ json }) => {
-          const schemaOptions = json.result.map(s => ({
-            value: s,
-            label: s,
-            title: s,
-          }));
-          this.setState({ schemaOptions, schemaLoading: false });
-          this.props.onSchemasLoad(schemaOptions);
-        })
-        .catch(() => {
-          this.setState({ schemaLoading: false, schemaOptions: [] });
-          this.props.handleError(t('Error while fetching schema list'));
-        });
-    }
-    return Promise.resolve();
-  }
-
-  changeDataBase(db, force = false) {
-    const dbId = db ? db.id : null;
-    this.setState({ schemaOptions: [] });
-    this.props.onSchemaChange(null);
-    this.props.onDbChange(db);
-    this.fetchSchemas(dbId, force);
-    this.setState(
-      { dbId, schema: null, tableName: null, tableOptions: [] },
-      this.onChange,
-    );
-  }
-
-  changeSchema(schemaOpt, force = false) {
-    const schema = schemaOpt ? schemaOpt.value : null;
-    this.props.onSchemaChange(schema);
-    this.setState({ schema }, () => {
-      this.fetchTables(force);
-      this.onChange();
-    });
-  }
-
-  changeTable(tableOpt) {
-    if (!tableOpt) {
-      this.setState({ tableName: '' });
-      return;
-    }
-    const schemaName = tableOpt.schema;
-    const tableName = tableOpt.value;
-    if (this.props.tableNameSticky) {
-      this.setState({ tableName }, this.onChange);
-    }
-    this.props.onTableChange(tableName, schemaName);
-  }
-
-  renderDatabaseOption(db) {
-    return (
-      <span title={db.database_name}>
-        <Label bsStyle="default">{db.backend}</Label>
-        {db.database_name}
-      </span>
-    );
-  }
-
-  renderTableOption(option) {
-    return (
-      <span className="TableLabel" title={option.label}>
-        <span className="m-r-5">
-          <small className="text-muted">
-            <i
-              className={`fa fa-${option.type === 'view' ? 'eye' : 'table'}`}
-            />
-          </small>
-        </span>
-        {option.label}
-      </span>
-    );
-  }
-
-  renderSelectRow(select, refreshBtn) {
-    return (
-      <div className="section">
-        <span className="select">{select}</span>
-        <span className="refresh-col">{refreshBtn}</span>
-      </div>
-    );
-  }
-
-  renderDatabaseSelect() {
-    const queryParams = rison.encode({
-      order_columns: 'database_name',
-      order_direction: 'asc',
-      page: 0,
-      page_size: -1,
-      ...(this.props.formMode
-        ? {}
-        : {
-            filters: [
-              {
-                col: 'expose_in_sqllab',
-                opr: 'eq',
-                value: true,
-              },
-            ],
-          }),
-    });
-
-    return this.renderSelectRow(
-      <SupersetAsyncSelect
-        dataEndpoint={`/api/v1/database/?q=${queryParams}`}
-        onChange={this.onDatabaseChange}
-        onAsyncError={() =>
-          this.props.handleError(t('Error while fetching database list'))
-        }
-        clearable={false}
-        value={this.state.dbId}
-        valueKey="id"
-        valueRenderer={db => (
-          <div>
-            <span className="text-muted m-r-5">{t('Database:')}</span>
-            {this.renderDatabaseOption(db)}
-          </div>
-        )}
-        optionRenderer={this.renderDatabaseOption}
-        mutator={this.dbMutator}
-        placeholder={t('Select a database')}
-        isDisabled={!this.props.isDatabaseSelectEnabled}
-        autoSelect
-      />,
-    );
-  }
-
-  renderSchema() {
-    const refresh = !this.props.formMode && (
-      <RefreshLabel
-        onClick={() => this.onDatabaseChange({ id: this.props.dbId }, true)}
-        tooltipContent={t('Force refresh schema list')}
-      />
-    );
-    return this.renderSelectRow(
-      <Select
-        name="select-schema"
-        placeholder={t('Select a schema (%s)', this.state.schemaOptions.length)}
-        options={this.state.schemaOptions}
-        value={this.props.schema}
-        valueRenderer={o => (
-          <div>
-            <span className="text-muted">{t('Schema:')}</span> {o.label}
-          </div>
-        )}
-        isLoading={this.state.schemaLoading}
-        autosize={false}
-        onChange={this.onSchemaChange}
-      />,
-      refresh,
-    );
-  }
-
-  renderTable() {
-    let tableSelectPlaceholder;
-    let tableSelectDisabled = false;
-    if (
-      this.props.database &&
-      this.props.database.allow_multi_schema_metadata_fetch
-    ) {
-      tableSelectPlaceholder = t('Type to search ...');
-    } else {
-      tableSelectPlaceholder = t('Select table ');
-      tableSelectDisabled = true;
-    }
-    const options = this.state.tableOptions;
-    let select = null;
-    if (this.props.schema && !this.props.formMode) {
-      select = (
-        <Select
-          name="select-table"
-          isLoading={this.state.tableLoading}
-          ignoreAccents={false}
-          placeholder={t('Select table or type table name')}
-          autosize={false}
-          onChange={this.changeTable}
-          options={options}
-          value={this.state.tableName}
-          optionRenderer={this.renderTableOption}
-        />
-      );
-    } else if (this.props.formMode) {
-      select = (
-        <CreatableSelect
-          name="select-table"
-          isLoading={this.state.tableLoading}
-          ignoreAccents={false}
-          placeholder={t('Select table or type table name')}
-          autosize={false}
-          onChange={this.changeTable}
-          options={options}
-          value={this.state.tableName}
-          optionRenderer={this.renderTableOption}
-        />
-      );
-    } else {
-      select = (
-        <AsyncSelect
-          name="async-select-table"
-          placeholder={tableSelectPlaceholder}
-          isDisabled={tableSelectDisabled}
-          autosize={false}
-          onChange={this.changeTable}
-          value={this.state.tableName}
-          loadOptions={this.getTableNamesBySubStr}
-          optionRenderer={this.renderTableOption}
-        />
-      );
-    }
-    const refresh = !this.props.formMode && (
-      <RefreshLabel
-        onClick={() => this.changeSchema({ value: this.props.schema }, true)}
-        tooltipContent={t('Force refresh table list')}
-      />
-    );
-    return this.renderSelectRow(select, refresh);
-  }
-
-  renderSeeTableLabel() {
-    return (
-      <div className="section">
-        <FormLabel>
-          {t('See table schema')}{' '}
-          {this.props.schema && (
-            <small>
-              ({this.state.tableOptions.length} in <i>{this.props.schema}</i>)
-            </small>
-          )}
-        </FormLabel>
-      </div>
-    );
-  }
-
-  render() {
-    return (
-      <div className="TableSelector">
-        {this.props.formMode && <FieldTitle>{t('datasource')}</FieldTitle>}
-        {this.renderDatabaseSelect()}
-        {this.props.formMode && <FieldTitle>{t('schema')}</FieldTitle>}
-        {this.renderSchema()}
-        {!this.props.formMode && <div className="divider" />}
-        {this.props.sqlLabMode && this.renderSeeTableLabel()}
-        {this.props.formMode && <FieldTitle>{t('Table')}</FieldTitle>}
-        {this.renderTable()}
-      </div>
-    );
-  }
-}
-TableSelector.propTypes = propTypes;
-TableSelector.defaultProps = defaultProps;
diff --git a/superset-frontend/src/components/TableSelector.tsx b/superset-frontend/src/components/TableSelector.tsx
new file mode 100644
index 0000000..4c59359
--- /dev/null
+++ b/superset-frontend/src/components/TableSelector.tsx
@@ -0,0 +1,379 @@
+/**
+ * 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, {
+  FunctionComponent,
+  useEffect,
+  useState,
+  ReactNode,
+} from 'react';
+import { styled, SupersetClient, t } from '@superset-ui/core';
+import { AsyncSelect, CreatableSelect, Select } from 'src/components/Select';
+
+import FormLabel from 'src/components/FormLabel';
+
+import DatabaseSelector from './DatabaseSelector';
+import RefreshLabel from './RefreshLabel';
+
+const FieldTitle = styled.p`
+  color: ${({ theme }) => theme.colors.secondary.light2};
+  font-size: ${({ theme }) => theme.typography.sizes.s}px;
+  margin: 20px 0 10px 0;
+  text-transform: uppercase;
+`;
+
+const TableSelectorWrapper = styled.div`
+  .fa-refresh {
+    padding-left: 9px;
+  }
+
+  .refresh-col {
+    display: flex;
+    align-items: center;
+    width: 30px;
+  }
+
+  .section {
+    padding-bottom: 5px;
+    display: flex;
+    flex-direction: row;
+  }
+
+  .select {
+    flex-grow: 1;
+  }
+
+  .divider {
+    border-bottom: 1px solid ${({ theme }) => theme.colors.secondary.light5};
+    margin: 15px 0;
+  }
+`;
+
+const TableLabel = styled.span`
+  white-space: nowrap;
+`;
+
+interface TableSelectorProps {
+  clearable?: boolean;
+  database?: any;
+  dbId: number;
+  formMode?: boolean;
+  getDbList?: (arg0: any) => {};
+  handleError: (msg: string) => void;
+  isDatabaseSelectEnabled?: boolean;
+  onChange?: ({
+    dbId,
+    schema,
+  }: {
+    dbId: number;
+    schema?: string;
+    tableName?: string;
+  }) => void;
+  onDbChange?: (db: any) => void;
+  onSchemaChange?: (arg0?: any) => {};
+  onSchemasLoad?: () => void;
+  onTableChange?: (tableName: string, schema: string) => void;
+  onTablesLoad?: (options: Array<any>) => {};
+  schema?: string;
+  sqlLabMode?: boolean;
+  tableName?: string;
+  tableNameSticky?: boolean;
+}
+
+const TableSelector: FunctionComponent<TableSelectorProps> = ({
+  database,
+  dbId,
+  formMode = false,
+  getDbList,
+  handleError,
+  isDatabaseSelectEnabled = true,
+  onChange,
+  onDbChange,
+  onSchemaChange,
+  onSchemasLoad,
+  onTableChange,
+  onTablesLoad,
+  schema,
+  sqlLabMode = true,
+  tableName,
+  tableNameSticky = true,
+}) => {
+  const [currentSchema, setCurrentSchema] = useState<string | undefined>(
+    schema,
+  );
+  const [currentTableName, setCurrentTableName] = useState<string | undefined>(
+    tableName,
+  );
+  const [tableLoading, setTableLoading] = useState(false);
+  const [tableOptions, setTableOptions] = useState([]);
+
+  function fetchTables(
+    databaseId?: number,
+    schema?: string,
+    forceRefresh = false,
+    substr = 'undefined',
+  ) {
+    const dbSchema = schema || currentSchema;
+    const actualDbId = databaseId || dbId;
+    if (actualDbId && dbSchema) {
+      const encodedSchema = encodeURIComponent(dbSchema);
+      const encodedSubstr = encodeURIComponent(substr);
+      setTableLoading(true);
+      setTableOptions([]);
+      const endpoint = encodeURI(
+        `/superset/tables/${actualDbId}/${encodedSchema}/${encodedSubstr}/${!!forceRefresh}/`,
+      );
+      return SupersetClient.get({ endpoint })
+        .then(({ json }) => {
+          const options = json.options.map((o: any) => ({
+            value: o.value,
+            schema: o.schema,
+            label: o.label,
+            title: o.title,
+            type: o.type,
+          }));
+          setTableLoading(false);
+          setTableOptions(options);
+          if (onTablesLoad) {
+            onTablesLoad(json.options);
+          }
+        })
+        .catch(() => {
+          setTableLoading(false);
+          setTableOptions([]);
+          handleError(t('Error while fetching table list'));
+        });
+    }
+    setTableLoading(false);
+    setTableOptions([]);
+    return Promise.resolve();
+  }
+
+  useEffect(() => {
+    if (dbId && schema) {
+      fetchTables();
+    }
+  }, [dbId, schema]);
+
+  function onSelectionChange({
+    dbId,
+    schema,
+    tableName,
+  }: {
+    dbId: number;
+    schema?: string;
+    tableName?: string;
+  }) {
+    setCurrentTableName(tableName);
+    setCurrentSchema(schema);
+    if (onChange) {
+      onChange({ dbId, schema, tableName });
+    }
+  }
+
+  function getTableNamesBySubStr(substr = 'undefined') {
+    if (!dbId || !substr) {
+      const options: any[] = [];
+      return Promise.resolve({ options });
+    }
+    const encodedSchema = encodeURIComponent(schema || '');
+    const encodedSubstr = encodeURIComponent(substr);
+    return SupersetClient.get({
+      endpoint: encodeURI(
+        `/superset/tables/${dbId}/${encodedSchema}/${encodedSubstr}`,
+      ),
+    }).then(({ json }) => {
+      const options = json.options.map((o: any) => ({
+        value: o.value,
+        schema: o.schema,
+        label: o.label,
+        title: o.title,
+        type: o.type,
+      }));
+      return { options };
+    });
+  }
+
+  function changeTable(tableOpt: any) {
+    if (!tableOpt) {
+      setCurrentTableName('');
+      return;
+    }
+    const schemaName = tableOpt.schema;
+    const tableOptTableName = tableOpt.value;
+    if (tableNameSticky) {
+      onSelectionChange({
+        dbId,
+        schema: schemaName,
+        tableName: tableOptTableName,
+      });
+    }
+    if (onTableChange) {
+      onTableChange(tableOptTableName, schemaName);
+    }
+  }
+
+  function changeSchema(schemaOpt: any, force = false) {
+    const value = schemaOpt ? schemaOpt.value : null;
+    if (onSchemaChange) {
+      onSchemaChange(value);
+    }
+    onSelectionChange({
+      dbId,
+      schema: value,
+      tableName: undefined,
+    });
+    fetchTables(dbId, currentSchema, force);
+  }
+
+  function renderTableOption(option: any) {
+    return (
+      <TableLabel title={option.label}>
+        <span className="m-r-5">
+          <small className="text-muted">
+            <i
+              className={`fa fa-${option.type === 'view' ? 'eye' : 'table'}`}
+            />
+          </small>
+        </span>
+        {option.label}
+      </TableLabel>
+    );
+  }
+
+  function renderSelectRow(select: ReactNode, refreshBtn: ReactNode) {
+    return (
+      <div className="section">
+        <span className="select">{select}</span>
+        <span className="refresh-col">{refreshBtn}</span>
+      </div>
+    );
+  }
+
+  function renderDatabaseSelector() {
+    return (
+      <DatabaseSelector
+        dbId={dbId}
+        formMode={formMode}
+        getDbList={getDbList}
+        getTableList={fetchTables}
+        handleError={handleError}
+        onChange={onSelectionChange}
+        onDbChange={onDbChange}
+        onSchemaChange={onSchemaChange}
+        onSchemasLoad={onSchemasLoad}
+        schema={currentSchema}
+        sqlLabMode={sqlLabMode}
+        isDatabaseSelectEnabled={isDatabaseSelectEnabled}
+      />
+    );
+  }
+
+  function renderTableSelect() {
+    let tableSelectPlaceholder;
+    let tableSelectDisabled = false;
+    if (database && database.allow_multi_schema_metadata_fetch) {
+      tableSelectPlaceholder = t('Type to search ...');
+    } else {
+      tableSelectPlaceholder = t('Select table ');
+      tableSelectDisabled = true;
+    }
+    const options = tableOptions;
+    let select = null;
+    if (currentSchema && !formMode) {
+      select = (
+        <Select
+          name="select-table"
+          isLoading={tableLoading}
+          ignoreAccents={false}
+          placeholder={t('Select table or type table name')}
+          autosize={false}
+          onChange={changeTable}
+          options={options}
+          // @ts-ignore
+          value={currentTableName}
+          optionRenderer={renderTableOption}
+        />
+      );
+    } else if (formMode) {
+      select = (
+        <CreatableSelect
+          name="select-table"
+          isLoading={tableLoading}
+          ignoreAccents={false}
+          placeholder={t('Select table or type table name')}
+          autosize={false}
+          onChange={changeTable}
+          options={options}
+          // @ts-ignore
+          value={currentTableName}
+          optionRenderer={renderTableOption}
+        />
+      );
+    } else {
+      select = (
+        <AsyncSelect
+          name="async-select-table"
+          placeholder={tableSelectPlaceholder}
+          isDisabled={tableSelectDisabled}
+          autosize={false}
+          onChange={changeTable}
+          // @ts-ignore
+          value={currentTableName}
+          loadOptions={getTableNamesBySubStr}
+          optionRenderer={renderTableOption}
+        />
+      );
+    }
+    const refresh = !formMode && (
+      <RefreshLabel
+        onClick={() => changeSchema({ value: schema }, true)}
+        tooltipContent={t('Force refresh table list')}
+      />
+    );
+    return renderSelectRow(select, refresh);
+  }
+
+  function renderSeeTableLabel() {
+    return (
+      <div className="section">
+        <FormLabel>
+          {t('See table schema')}{' '}
+          {schema && (
+            <small>
+              {tableOptions.length} in
+              <i>{schema}</i>
+            </small>
+          )}
+        </FormLabel>
+      </div>
+    );
+  }
+
+  return (
+    <TableSelectorWrapper>
+      {renderDatabaseSelector()}
+      {!formMode && <div className="divider" />}
+      {sqlLabMode && renderSeeTableLabel()}
+      {formMode && <FieldTitle>{t('Table')}</FieldTitle>}
+      {renderTableSelect()}
+    </TableSelectorWrapper>
+  );
+};
+
+export default TableSelector;
diff --git a/superset-frontend/src/datasource/DatasourceEditor.jsx b/superset-frontend/src/datasource/DatasourceEditor.jsx
index d36bfae..c35a364 100644
--- a/superset-frontend/src/datasource/DatasourceEditor.jsx
+++ b/superset-frontend/src/datasource/DatasourceEditor.jsx
@@ -18,15 +18,16 @@
  */
 import React from 'react';
 import PropTypes from 'prop-types';
-import { Alert, Badge, Col, Tabs, Tab, Well } from 'react-bootstrap';
+import { Alert, Badge, Col, Radio, Tabs, Tab, Well } from 'react-bootstrap';
 import shortid from 'shortid';
 import { styled, SupersetClient, t } from '@superset-ui/core';
 
-import Label from 'src/components/Label';
 import Button from 'src/components/Button';
+import CertifiedIconWithTooltip from 'src/components/CertifiedIconWithTooltip';
+import DatabaseSelector from 'src/components/DatabaseSelector';
+import Label from 'src/components/Label';
 import Loading from 'src/components/Loading';
 import TableSelector from 'src/components/TableSelector';
-import CertifiedIconWithTooltip from 'src/components/CertifiedIconWithTooltip';
 import EditableTitle from 'src/components/EditableTitle';
 
 import getClientErrorObject from 'src/utils/getClientErrorObject';
@@ -74,6 +75,15 @@ const checkboxGenerator = (d, onChange) => (
 );
 const DATA_TYPES = ['STRING', 'NUMERIC', 'DATETIME'];
 
+const DATASOURCE_TYPES_ARR = [
+  { key: 'physical', label: t('Physical (table or view)') },
+  { key: 'virtual', label: t('Virtual (SQL)') },
+];
+const DATASOURCE_TYPES = {};
+DATASOURCE_TYPES_ARR.forEach(o => {
+  DATASOURCE_TYPES[o.key] = o;
+});
+
 function CollectionTabTitle({ title, collection }) {
   return (
     <div>
@@ -278,7 +288,10 @@ class DatasourceEditor extends React.PureComponent {
         col => !!col.expression,
       ),
       metadataLoading: false,
-      activeTabKey: 1,
+      activeTabKey: 0,
+      datasourceType: props.datasource.sql
+        ? DATASOURCE_TYPES.virtual.key
+        : DATASOURCE_TYPES.physical.key,
     };
 
     this.onChange = this.onChange.bind(this);
@@ -291,11 +304,19 @@ class DatasourceEditor extends React.PureComponent {
   }
 
   onChange() {
-    const datasource = {
+    // Emptying SQL if "Physical" radio button is selected
+    // Currently the logic to know whether the source is
+    // physical or virtual is based on whether SQL is empty or not.
+    const { datasourceType, datasource } = this.state;
+    const sql =
+      datasourceType === DATASOURCE_TYPES.physical.key ? '' : datasource.sql;
+
+    const newDatasource = {
       ...this.state.datasource,
+      sql,
       columns: [...this.state.databaseColumns, ...this.state.calculatedColumns],
     };
-    this.props.onChange(datasource, this.state.errors);
+    this.props.onChange(newDatasource, this.state.errors);
   }
 
   onDatasourceChange(datasource) {
@@ -310,6 +331,10 @@ class DatasourceEditor extends React.PureComponent {
     );
   }
 
+  onDatasourceTypeChange(datasourceType) {
+    this.setState({ datasourceType });
+  }
+
   setColumns(obj) {
     this.setState(obj, this.validateAndChange);
   }
@@ -466,34 +491,6 @@ class DatasourceEditor extends React.PureComponent {
         item={datasource}
         onChange={this.onDatasourceChange}
       >
-        {this.state.isSqla && (
-          <Field
-            fieldKey="tableSelector"
-            label={t('Physical Table')}
-            control={
-              <TableSelector
-                dbId={datasource.database.id}
-                schema={datasource.schema}
-                tableName={datasource.datasource_name}
-                onSchemaChange={schema =>
-                  this.onDatasourcePropChange('schema', schema)
-                }
-                onTableChange={table =>
-                  this.onDatasourcePropChange('datasource_name', table)
-                }
-                sqlLabMode={false}
-                clearable={false}
-                handleError={this.props.addDangerToast}
-                isDatabaseSelectEnabled={false}
-              />
-            }
-            description={t(
-              'The pointer to a physical table. Keep in mind that the chart is ' +
-                'associated to this Superset logical table, and this logical table points ' +
-                'the physical table referenced here.',
-            )}
-          />
-        )}
         <Field
           fieldKey="description"
           label={t('Description')}
@@ -559,32 +556,6 @@ class DatasourceEditor extends React.PureComponent {
         item={datasource}
         onChange={this.onDatasourceChange}
       >
-        {this.state.isSqla && (
-          <Field
-            fieldKey="sql"
-            label={t('SQL')}
-            description={t(
-              'When specifying SQL, the dataset acts as a view. ' +
-                'Superset will use this statement as a subquery while grouping and filtering ' +
-                'on the generated parent queries.',
-            )}
-            control={
-              <TextAreaControl language="sql" offerEditInModal={false} />
-            }
-          />
-        )}
-        {this.state.isDruid && (
-          <Field
-            fieldKey="json"
-            label={t('JSON')}
-            description={
-              <div>{t('The JSON metric or post aggregation definition.')}</div>
-            }
-            control={
-              <TextAreaControl language="json" offerEditInModal={false} />
-            }
-          />
-        )}
         <Field
           fieldKey="cache_timeout"
           label={t('Cache Timeout')}
@@ -645,6 +616,121 @@ class DatasourceEditor extends React.PureComponent {
     );
   }
 
+  renderSourceFieldset() {
+    const { datasource } = this.state;
+    return (
+      <div>
+        <div className="m-l-10 m-t-20 m-b-10">
+          {DATASOURCE_TYPES_ARR.map(type => (
+            <Radio
+              value={type.key}
+              inline
+              onChange={this.onDatasourceTypeChange.bind(this, type.key)}
+              checked={this.state.datasourceType === type.key}
+            >
+              {type.label}
+            </Radio>
+          ))}
+        </div>
+        <hr />
+        <Fieldset item={datasource} onChange={this.onDatasourceChange} compact>
+          {this.state.datasourceType === DATASOURCE_TYPES.virtual.key && (
+            <div>
+              {this.state.isSqla && (
+                <>
+                  <Field
+                    fieldKey="databaseSelector"
+                    label={t('virtual')}
+                    control={
+                      <DatabaseSelector
+                        dbId={datasource.database.id}
+                        schema={datasource.schema}
+                        onSchemaChange={schema =>
+                          this.onDatasourcePropChange('schema', schema)
+                        }
+                        onDbChange={database =>
+                          this.onDatasourcePropChange('database', database)
+                        }
+                        formMode={false}
+                        handleError={this.props.addDangerToast}
+                      />
+                    }
+                  />
+                  <Field
+                    fieldKey="sql"
+                    label={t('SQL')}
+                    description={t(
+                      'When specifying SQL, the datasource acts as a view. ' +
+                        'Superset will use this statement as a subquery while grouping and filtering ' +
+                        'on the generated parent queries.',
+                    )}
+                    control={
+                      <TextAreaControl
+                        language="sql"
+                        offerEditInModal={false}
+                        minLines={25}
+                        maxLines={25}
+                      />
+                    }
+                  />
+                </>
+              )}
+              {this.state.isDruid && (
+                <Field
+                  fieldKey="json"
+                  label={t('JSON')}
+                  description={
+                    <div>
+                      {t('The JSON metric or post aggregation definition.')}
+                    </div>
+                  }
+                  control={
+                    <TextAreaControl language="json" offerEditInModal={false} />
+                  }
+                />
+              )}
+            </div>
+          )}
+          {this.state.datasourceType === DATASOURCE_TYPES.physical.key && (
+            <Col md={6}>
+              {this.state.isSqla && (
+                <Field
+                  fieldKey="tableSelector"
+                  label={t('Physical')}
+                  control={
+                    <TableSelector
+                      clearable={false}
+                      dbId={datasource.database.id}
+                      handleError={this.props.addDangerToast}
+                      schema={datasource.schema}
+                      sqlLabMode={false}
+                      tableName={datasource.table_name}
+                      onSchemaChange={schema =>
+                        this.onDatasourcePropChange('schema', schema)
+                      }
+                      onDbChange={database =>
+                        this.onDatasourcePropChange('database', database)
+                      }
+                      onTableChange={table => {
+                        this.onDatasourcePropChange('table_name', table);
+                      }}
+                      isDatabaseSelectEnabled={false}
+                    />
+                  }
+                  description={t(
+                    'The pointer to a physical table (or view). Keep in mind that the chart is ' +
+                      'associated to this Superset logical table, and this logical table points ' +
+                      'the physical table referenced here.',
+                  )}
+                />
+              )}
+            </Col>
+          )}
+        </Fieldset>
+      </div>
+    );
+  }
+
   renderErrors() {
     if (this.state.errors.length > 0) {
       return (
@@ -800,6 +886,9 @@ class DatasourceEditor extends React.PureComponent {
           onSelect={this.handleTabSelect}
           defaultActiveKey={activeTabKey}
         >
+          <Tab eventKey={0} title={t('Source')}>
+            {activeTabKey === 0 && this.renderSourceFieldset()}
+          </Tab>
           <Tab
             title={
               <CollectionTabTitle
diff --git a/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx b/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx
index 9edd4db..2acf238 100644
--- a/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx
+++ b/superset-frontend/src/views/CRUD/data/dataset/AddDatasetModal.tsx
@@ -44,10 +44,8 @@ const StyledIcon = styled(Icon)`
 `;
 
 const TableSelectorContainer = styled.div`
-  .TableSelector {
-    padding-bottom: 340px;
-    width: 65%;
-  }
+  padding-bottom: 340px;
+  width: 65%;
 `;
 
 const DatasetModal: FunctionComponent<DatasetModalProps> = ({
@@ -59,7 +57,7 @@ const DatasetModal: FunctionComponent<DatasetModalProps> = ({
 }) => {
   const [currentSchema, setSchema] = useState('');
   const [currentTableName, setTableName] = useState('');
-  const [datasourceId, setDatasourceId] = useState<number | null>(null);
+  const [datasourceId, setDatasourceId] = useState<number>(0);
   const [disableSave, setDisableSave] = useState(true);
 
   const onChange = ({
@@ -128,7 +126,6 @@ const DatasetModal: FunctionComponent<DatasetModalProps> = ({
           handleError={addDangerToast}
           onChange={onChange}
           schema={currentSchema}
-          sqlLabMode={false}
           tableName={currentTableName}
         />
       </TableSelectorContainer>
diff --git a/superset-frontend/stylesheets/superset.less b/superset-frontend/stylesheets/superset.less
index 39305f2..2143fcc 100644
--- a/superset-frontend/stylesheets/superset.less
+++ b/superset-frontend/stylesheets/superset.less
@@ -319,6 +319,10 @@ table.table-no-hover tr:hover {
   margin-top: 10px;
 }
 
+.m-t-20 {
+  margin-top: 20px;
+}
+
 .m-b-10 {
   margin-bottom: 10px;
 }
diff --git a/tests/datasets/api_tests.py b/tests/datasets/api_tests.py
index 56f9ef8..b05a54b 100644
--- a/tests/datasets/api_tests.py
+++ b/tests/datasets/api_tests.py
@@ -826,10 +826,6 @@ class TestDatasetApi(SupersetTestCase):
         self.login(username="admin")
         rv = self.get_assert_metric(uri, "export")
         self.assertEqual(rv.status_code, 200)
-        self.assertEqual(
-            rv.headers["Content-Disposition"],
-            generate_download_headers("yaml")["Content-Disposition"],
-        )
 
         cli_export = export_to_dict(
             session=db.session,