You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by hu...@apache.org on 2021/05/21 22:26:34 UTC

[superset] branch master updated: feat: save database with new dynamic form (#14583)

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

hugh 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 c7aee4e  feat: save database with new dynamic form (#14583)
c7aee4e is described below

commit c7aee4e27bb379abb8a0328433c9149920812e05
Author: Elizabeth Thompson <es...@gmail.com>
AuthorDate: Fri May 21 15:25:56 2021 -0700

    feat: save database with new dynamic form (#14583)
    
    * split db modal file
    
    * split db modal file
    
    * hook up available databases
    
    * add comment
---
 .../src/views/CRUD/data/database/DatabaseList.tsx  |  19 +-
 .../DatabaseModal/DatabaseConnectionForm.tsx       | 158 ++++++++++++++
 .../data/database/DatabaseModal/ExtraOptions.tsx   |  11 +-
 .../data/database/DatabaseModal/SqlAlchemyForm.tsx |  17 +-
 .../data/database/DatabaseModal/index.test.jsx     |  84 +++++++-
 .../CRUD/data/database/DatabaseModal/index.tsx     | 233 ++++++++++++++++-----
 .../CRUD/data/database/DatabaseModal/styles.ts     | 198 ++++++++++++-----
 .../src/views/CRUD/data/database/types.ts          |  50 +++++
 superset-frontend/src/views/CRUD/hooks.ts          |  18 +-
 superset/databases/schemas.py                      |   1 +
 10 files changed, 658 insertions(+), 131 deletions(-)

diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
index 51808df..d1105e7 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
@@ -29,8 +29,8 @@ import { Tooltip } from 'src/components/Tooltip';
 import Icons from 'src/components/Icons';
 import ListView, { FilterOperator, Filters } from 'src/components/ListView';
 import { commonMenuData } from 'src/views/CRUD/data/common';
+import DatabaseModal from 'src/views/CRUD/data/database/DatabaseModal';
 import ImportModelsModal from 'src/components/ImportModal/index';
-import DatabaseModal from './DatabaseModal';
 import { DatabaseObject } from './types';
 
 const PAGE_SIZE = 25;
@@ -147,10 +147,13 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
     );
   }
 
-  function handleDatabaseEdit(database: DatabaseObject) {
-    // Set database and open modal
+  function handleDatabaseEditModal({
+    database = null,
+    modalOpen = false,
+  }: { database?: DatabaseObject | null; modalOpen?: boolean } = {}) {
+    // Set database and modal
     setCurrentDatabase(database);
-    setDatabaseModalOpen(true);
+    setDatabaseModalOpen(modalOpen);
   }
 
   const canCreate = hasPerm('can_write');
@@ -176,8 +179,7 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
         buttonStyle: 'primary',
         onClick: () => {
           // Ensure modal will be opened in add mode
-          setCurrentDatabase(null);
-          setDatabaseModalOpen(true);
+          handleDatabaseEditModal({ modalOpen: true });
         },
       },
     ];
@@ -298,7 +300,8 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
       },
       {
         Cell: ({ row: { original } }: any) => {
-          const handleEdit = () => handleDatabaseEdit(original);
+          const handleEdit = () =>
+            handleDatabaseEditModal({ database: original, modalOpen: true });
           const handleDelete = () => openDatabaseDeleteModal(original);
           const handleExport = () => handleDatabaseExport(original);
           if (!canEdit && !canDelete && !canExport) {
@@ -416,7 +419,7 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
       <DatabaseModal
         databaseId={currentDatabase?.id}
         show={databaseModalOpen}
-        onHide={() => setDatabaseModalOpen(false)}
+        onHide={handleDatabaseEditModal}
         onDatabaseAdd={() => {
           refreshData();
         }}
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm.tsx
new file mode 100644
index 0000000..f0542e4
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm.tsx
@@ -0,0 +1,158 @@
+/**
+ * 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, { FormEvent } from 'react';
+import cx from 'classnames';
+import { InputProps } from 'antd/lib/input';
+import { FormLabel, FormItem } from 'src/components/Form';
+import { Input } from 'src/common/components';
+import { StyledFormHeader, formScrollableStyles } from './styles';
+import { DatabaseForm } from '../types';
+
+export const FormFieldOrder = [
+  'host',
+  'port',
+  'database',
+  'username',
+  'password',
+  'database_name',
+];
+
+const CHANGE_METHOD = {
+  onChange: 'onChange',
+  onPropertiesChange: 'onPropertiesChange',
+};
+
+const FORM_FIELD_MAP = {
+  host: {
+    description: 'Host',
+    type: 'text',
+    className: 'w-50',
+    placeholder: 'e.g. 127.0.0.1',
+    changeMethod: CHANGE_METHOD.onPropertiesChange,
+  },
+  port: {
+    description: 'Port',
+    type: 'text',
+    className: 'w-50',
+    placeholder: 'e.g. 5432',
+    changeMethod: CHANGE_METHOD.onPropertiesChange,
+  },
+  database: {
+    description: 'Database name',
+    type: 'text',
+    label:
+      'Copy the name of the PostgreSQL database you are trying to connect to.',
+    placeholder: 'e.g. world_population',
+    changeMethod: CHANGE_METHOD.onPropertiesChange,
+  },
+  username: {
+    description: 'Username',
+    type: 'text',
+    placeholder: 'e.g. Analytics',
+    changeMethod: CHANGE_METHOD.onPropertiesChange,
+  },
+  password: {
+    description: 'Password',
+    type: 'text',
+    placeholder: 'e.g. ********',
+    changeMethod: CHANGE_METHOD.onPropertiesChange,
+  },
+  database_name: {
+    description: 'Display Name',
+    type: 'text',
+    label: 'Pick a nickname for this database to display as in Superset.',
+    changeMethod: CHANGE_METHOD.onChange,
+  },
+  query: {
+    additionalProperties: {},
+    description: 'Additional parameters',
+    type: 'object',
+    changeMethod: CHANGE_METHOD.onPropertiesChange,
+  },
+};
+
+const DatabaseConnectionForm = ({
+  dbModel: { name, parameters },
+  onParametersChange,
+  onChange,
+}: {
+  dbModel: DatabaseForm;
+  onParametersChange: (
+    event: FormEvent<InputProps> | { target: HTMLInputElement },
+  ) => void;
+  onChange: (
+    event: FormEvent<InputProps> | { target: HTMLInputElement },
+  ) => void;
+}) => (
+  <>
+    <StyledFormHeader>
+      <h4>Enter the required {name} credentials</h4>
+      <p className="helper">
+        Need help? Learn more about connecting to {name}.
+      </p>
+    </StyledFormHeader>
+    <div css={formScrollableStyles}>
+      {parameters &&
+        FormFieldOrder.filter(
+          (key: string) =>
+            Object.keys(parameters.properties).includes(key) ||
+            key === 'database_name',
+        ).map(field => {
+          const {
+            className,
+            description,
+            type,
+            placeholder,
+            label,
+            changeMethod,
+          } = FORM_FIELD_MAP[field];
+          const onEdit =
+            changeMethod === CHANGE_METHOD.onChange
+              ? onChange
+              : onParametersChange;
+          return (
+            <FormItem
+              className={cx(className, `form-group-${className}`)}
+              key={field}
+            >
+              <FormLabel
+                htmlFor={field}
+                required={parameters.required.includes(field)}
+              >
+                {description}
+              </FormLabel>
+              <Input
+                name={field}
+                type={type}
+                id={field}
+                autoComplete="off"
+                placeholder={placeholder}
+                onChange={onEdit}
+              />
+              <p className="helper">{label}</p>
+            </FormItem>
+          );
+        })}
+    </div>
+  </>
+);
+
+export const FormFieldMap = FORM_FIELD_MAP;
+
+export default DatabaseConnectionForm;
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/ExtraOptions.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/ExtraOptions.tsx
index 8f005f9..e3987ad 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/ExtraOptions.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/ExtraOptions.tsx
@@ -18,7 +18,7 @@
  */
 import React, { ChangeEvent, EventHandler } from 'react';
 import cx from 'classnames';
-import { t } from '@superset-ui/core';
+import { t, SupersetTheme } from '@superset-ui/core';
 import InfoTooltip from 'src/components/InfoTooltip';
 import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
 import Collapse from 'src/components/Collapse';
@@ -26,7 +26,8 @@ import {
   StyledInputContainer,
   StyledJsonEditor,
   StyledExpandableForm,
-} from 'src/views/CRUD/data/database/DatabaseModal/styles';
+  antdCollapseStyles,
+} from './styles';
 import { DatabaseObject } from '../types';
 
 const defaultExtra =
@@ -48,7 +49,11 @@ const ExtraOptions = ({
   const createAsOpen = !!(db?.allow_ctas || db?.allow_cvas);
 
   return (
-    <Collapse expandIconPosition="right" accordion>
+    <Collapse
+      expandIconPosition="right"
+      accordion
+      css={(theme: SupersetTheme) => antdCollapseStyles(theme)}
+    >
       <Collapse.Panel
         header={
           <div>
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/SqlAlchemyForm.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/SqlAlchemyForm.tsx
index 0bc2e0c..cf442b4 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/SqlAlchemyForm.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/SqlAlchemyForm.tsx
@@ -17,9 +17,9 @@
  * under the License.
  */
 import React, { EventHandler, ChangeEvent, MouseEvent } from 'react';
-import { t, supersetTheme } from '@superset-ui/core';
+import { t, SupersetTheme } from '@superset-ui/core';
 import Button from 'src/components/Button';
-import { StyledInputContainer } from './styles';
+import { StyledInputContainer, wideButton } from './styles';
 
 import { DatabaseObject } from '../types';
 
@@ -45,7 +45,7 @@ const SqlAlchemyTab = ({
           type="text"
           name="database_name"
           value={db?.database_name || ''}
-          placeholder={t('Name your dataset')}
+          placeholder={t('Name your database')}
           onChange={onInputChange}
         />
       </div>
@@ -71,25 +71,22 @@ const SqlAlchemyTab = ({
         />
       </div>
       <div className="helper">
-        {t('Refer to the ')}
+        {t('Refer to the')}{' '}
         <a
           href={conf?.SQLALCHEMY_DOCS_URL ?? ''}
           target="_blank"
           rel="noopener noreferrer"
         >
           {conf?.SQLALCHEMY_DISPLAY_TEXT ?? ''}
-        </a>
-        {t(' for more information on how to structure your URI.')}
+        </a>{' '}
+        {t('for more information on how to structure your URI.')}
       </div>
     </StyledInputContainer>
     <Button
       onClick={testConnection}
       cta
       buttonStyle="link"
-      style={{
-        width: '100%',
-        border: `1px solid ${supersetTheme.colors.primary.base}`,
-      }}
+      css={(theme: SupersetTheme) => wideButton(theme)}
     >
       {t('Test connection')}
     </Button>
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx
index 27e841d..66d3138 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx
@@ -42,20 +42,35 @@ const mockedProps = {
 const dbProps = {
   show: true,
   databaseId: 10,
+  database_name: 'my database',
+  sqlalchemy_uri: 'postgres://superset:superset@something:1234/superset',
 };
 
 const DATABASE_ENDPOINT = 'glob:*/api/v1/database/*';
+const AVAILABLE_DB_ENDPOINT = 'glob:*/api/v1/database/available/*';
+fetchMock.config.overwriteRoutes = true;
 fetchMock.get(DATABASE_ENDPOINT, {
   result: {
-    id: 1,
+    id: 10,
     database_name: 'my database',
     expose_in_sqllab: false,
     allow_ctas: false,
     allow_cvas: false,
+    configuration_method: 'sqlalchemy_form',
   },
 });
+fetchMock.get(AVAILABLE_DB_ENDPOINT, {
+  databases: [
+    {
+      engine: 'mysql',
+      name: 'MySQL',
+      preferred: false,
+    },
+  ],
+});
 
 describe('DatabaseModal', () => {
+  afterEach(fetchMock.reset);
   describe('enzyme', () => {
     let wrapper;
     let spyOnUseSelector;
@@ -251,5 +266,72 @@ describe('DatabaseModal', () => {
       // Both checkboxes go unchecked, so the field should no longer render
       expect(schemaField).not.toHaveClass('open');
     });
+
+    describe('create database', () => {
+      it('should show a form when dynamic_form is selected', async () => {
+        const props = {
+          ...dbProps,
+          databaseId: null,
+          database_name: null,
+          sqlalchemy_uri: null,
+        };
+        render(<DatabaseModal {...props} />, { useRedux: true });
+        // it should have the correct header text
+        const headerText = screen.getByText(/connect a database/i);
+        expect(headerText).toBeVisible();
+
+        await screen.findByText(/display name/i);
+
+        // it does not fetch any databases if no id is passed in
+        expect(fetchMock.calls().length).toEqual(0);
+
+        // todo we haven't hooked this up to load dynamically yet so
+        // we can't currently test it
+      });
+    });
+
+    describe('edit database', () => {
+      it('renders the sqlalchemy form when the sqlalchemy_form configuration method is set', async () => {
+        render(<DatabaseModal {...dbProps} />, { useRedux: true });
+
+        // it should have tabs
+        const tabs = screen.getAllByRole('tab');
+        expect(tabs.length).toEqual(2);
+        expect(tabs[0]).toHaveTextContent('Basic');
+        expect(tabs[1]).toHaveTextContent('Advanced');
+
+        // it should have the correct header text
+        const headerText = screen.getByText(/edit database/i);
+        expect(headerText).toBeVisible();
+
+        // todo add more when this form is built out
+      });
+      it('renders the dynamic form when the dynamic_form configuration method is set', async () => {
+        fetchMock.get(DATABASE_ENDPOINT, {
+          result: {
+            id: 10,
+            database_name: 'my database',
+            expose_in_sqllab: false,
+            allow_ctas: false,
+            allow_cvas: false,
+            configuration_method: 'dynamic_form',
+            parameters: {
+              database: 'mydatabase',
+            },
+          },
+        });
+        render(<DatabaseModal {...dbProps} />, { useRedux: true });
+
+        await screen.findByText(/todo/i);
+
+        // // it should have tabs
+        const tabs = screen.getAllByRole('tab');
+        expect(tabs.length).toEqual(2);
+
+        // it should show a TODO for now
+        const todoText = screen.getAllByText(/todo/i);
+        expect(todoText[0]).toBeVisible();
+      });
+    });
   });
 });
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx
index 4547f8b..5cecee7 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { t } from '@superset-ui/core';
+import { t, SupersetTheme } from '@superset-ui/core';
 import React, {
   FunctionComponent,
   useEffect,
@@ -26,25 +26,39 @@ import React, {
 } from 'react';
 import Tabs from 'src/components/Tabs';
 import { Alert } from 'src/common/components';
+import Modal from 'src/components/Modal';
+import Button from 'src/components/Button';
 import withToasts from 'src/messageToasts/enhancers/withToasts';
 import {
   testDatabaseConnection,
   useSingleViewResource,
+  useAvailableDatabases,
 } from 'src/views/CRUD/hooks';
 import { useCommonConf } from 'src/views/CRUD/data/database/state';
-import { DatabaseObject } from 'src/views/CRUD/data/database/types';
+import {
+  DatabaseObject,
+  DatabaseForm,
+  CONFIGURATION_METHOD,
+} from 'src/views/CRUD/data/database/types';
 import ExtraOptions from './ExtraOptions';
 import SqlAlchemyForm from './SqlAlchemyForm';
+
+import DatabaseConnectionForm from './DatabaseConnectionForm';
 import {
-  StyledBasicTab,
-  StyledModal,
-  EditHeader,
-  EditHeaderTitle,
-  EditHeaderSubtitle,
+  antDAlertStyles,
+  antDModalNoPaddingStyles,
+  antDModalStyles,
+  antDTabsStyles,
+  buttonLinkStyles,
   CreateHeader,
   CreateHeaderSubtitle,
   CreateHeaderTitle,
-  Divider,
+  EditHeader,
+  EditHeaderSubtitle,
+  EditHeaderTitle,
+  formHelperStyles,
+  formStyles,
+  StyledBasicTab,
 } from './styles';
 
 const DOCUMENTATION_LINK =
@@ -60,11 +74,14 @@ interface DatabaseModalProps {
 }
 
 enum ActionType {
-  textChange,
-  inputChange,
+  configMethodChange,
+  dbSelected,
   editorChange,
   fetched,
+  inputChange,
+  parametersChange,
   reset,
+  textChange,
 }
 
 interface DBReducerPayloadType {
@@ -81,7 +98,8 @@ type DBReducerActionType =
       type:
         | ActionType.textChange
         | ActionType.inputChange
-        | ActionType.editorChange;
+        | ActionType.editorChange
+        | ActionType.parametersChange;
       payload: DBReducerPayloadType;
     }
   | {
@@ -89,7 +107,18 @@ type DBReducerActionType =
       payload: Partial<DatabaseObject>;
     }
   | {
+      type: ActionType.dbSelected;
+      payload: {
+        parameters: { engine?: string };
+        configuration_method: CONFIGURATION_METHOD;
+      };
+    }
+  | {
       type: ActionType.reset;
+    }
+  | {
+      type: ActionType.configMethodChange;
+      payload: { configuration_method: CONFIGURATION_METHOD };
     };
 
 function dbReducer(
@@ -114,6 +143,14 @@ function dbReducer(
         ...trimmedState,
         [action.payload.name]: action.payload.value,
       };
+    case ActionType.parametersChange:
+      return {
+        ...trimmedState,
+        parameters: {
+          ...trimmedState.parameters,
+          [action.payload.name]: action.payload.value,
+        },
+      };
     case ActionType.editorChange:
       return {
         ...trimmedState,
@@ -126,6 +163,15 @@ function dbReducer(
       };
     case ActionType.fetched:
       return {
+        parameters: {
+          engine: trimmedState.parameters?.engine,
+        },
+        configuration_method: trimmedState.configuration_method,
+        ...action.payload,
+      };
+    case ActionType.dbSelected:
+    case ActionType.configMethodChange:
+      return {
         ...action.payload,
       };
     case ActionType.reset:
@@ -135,6 +181,7 @@ function dbReducer(
 }
 
 const DEFAULT_TAB_KEY = '1';
+const FALSY_FORM_VALUES = [undefined, null, ''];
 
 const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
   addDangerToast,
@@ -148,11 +195,13 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
     Reducer<Partial<DatabaseObject> | null, DBReducerActionType>
   >(dbReducer, null);
   const [tabKey, setTabKey] = useState<string>(DEFAULT_TAB_KEY);
+  const [availableDbs, getAvailableDbs] = useAvailableDatabases();
+  const [hasConnectedDb, setHasConnectedDb] = useState<boolean>(false);
   const conf = useCommonConf();
 
   const isEditMode = !!databaseId;
-  const useSqlAlchemyForm = true; // TODO: set up logic
-  const hasConnectedDb = false; // TODO: set up logic
+  const useSqlAlchemyForm =
+    db?.configuration_method === CONFIGURATION_METHOD.SQLALCHEMY_URI;
 
   // Database fetch logic
   const {
@@ -187,40 +236,39 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
 
   const onClose = () => {
     setDB({ type: ActionType.reset });
+    setHasConnectedDb(false);
     onHide();
   };
 
   const onSave = () => {
-    if (isEditMode) {
-      // databaseId will not be null if isEditMode is true
-      // db will have at least a database_name and  sqlalchemy_uri
-      // in order for the button to not be disabled
-      updateResource(databaseId as number, db as DatabaseObject).then(
-        result => {
-          if (result) {
-            if (onDatabaseAdd) {
-              onDatabaseAdd();
-            }
-            onClose();
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    const { id, ...update } = db || {};
+    if (db?.id) {
+      if (db.sqlalchemy_uri) {
+        // don't pass parameters if using the sqlalchemy uri
+        delete update.parameters;
+      }
+      updateResource(db.id as number, update as DatabaseObject).then(result => {
+        if (result) {
+          if (onDatabaseAdd) {
+            onDatabaseAdd();
           }
-        },
-      );
+          onClose();
+        }
+      });
     } else if (db) {
       // Create
-      db.database_name = db?.database_name?.trim();
-      createResource(db as DatabaseObject).then(dbId => {
+      createResource(update as DatabaseObject).then(dbId => {
         if (dbId) {
+          setHasConnectedDb(true);
           if (onDatabaseAdd) {
             onDatabaseAdd();
           }
-          onClose();
         }
       });
     }
   };
 
-  const disableSave = !(db?.database_name?.trim() && db?.sqlalchemy_uri);
-
   const onChange = (type: any, payload: any) => {
     setDB({ type, payload } as DBReducerActionType);
   };
@@ -244,6 +292,14 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
   useEffect(() => {
     if (show) {
       setTabKey(DEFAULT_TAB_KEY);
+      getAvailableDbs();
+      setDB({
+        type: ActionType.dbSelected,
+        payload: {
+          parameters: { engine: 'postgresql' },
+          configuration_method: CONFIGURATION_METHOD.SQLALCHEMY_URI,
+        }, // todo hook this up to step 1
+      });
     }
     if (databaseId && show) {
       fetchDB();
@@ -251,13 +307,10 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
   }, [show, databaseId]);
 
   useEffect(() => {
-    // TODO: can we include these values in the original fetch?
     if (dbFetched) {
       setDB({
         type: ActionType.fetched,
-        payload: {
-          ...dbFetched,
-        },
+        payload: dbFetched,
       });
     }
   }, [dbFetched]);
@@ -266,10 +319,32 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
     setTabKey(key);
   };
 
+  const dbModel: DatabaseForm =
+    availableDbs?.databases?.find(
+      (available: { engine: string | undefined }) =>
+        available.engine === db?.parameters?.engine,
+    ) || {};
+
+  const disableSave =
+    !hasConnectedDb &&
+    (useSqlAlchemyForm
+      ? !(db?.database_name?.trim() && db?.sqlalchemy_uri)
+      : // disable the button if there is no dbModel.parameters or if
+        // any required fields are falsy
+        !dbModel?.parameters ||
+        !!dbModel.parameters.required.filter(field =>
+          FALSY_FORM_VALUES.includes(db?.parameters?.[field]),
+        ).length);
+
   return isEditMode || useSqlAlchemyForm ? (
-    <StyledModal
+    <Modal
+      css={(theme: SupersetTheme) => [
+        antDTabsStyles,
+        antDModalStyles(theme),
+        antDModalNoPaddingStyles,
+        formHelperStyles(theme),
+      ]}
       name="database"
-      className="database-modal"
       disablePrimaryButton={disableSave}
       height="600px"
       onHandledPrimaryAction={onSave}
@@ -302,11 +377,12 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
           </CreateHeaderSubtitle>
         </CreateHeader>
       )}
-      <Divider />
+      <hr />
       <Tabs
         defaultActiveKey={DEFAULT_TAB_KEY}
         activeKey={tabKey}
         onTabClick={tabChange}
+        animated={{ inkBar: true, tabPane: true }}
       >
         <StyledBasicTab tab={<span>{t('Basic')}</span>} key="1">
           {useSqlAlchemyForm ? (
@@ -325,16 +401,17 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
             />
           ) : (
             <div>
-              <p>TODO: db form</p>
+              <p>TODO: form</p>
             </div>
           )}
           <Alert
+            css={(theme: SupersetTheme) => antDAlertStyles(theme)}
             message="Additional fields may be required"
             description={
               <>
                 Select databases require additional fields to be completed in
-                the next step to successfully connect the database. Learn what
-                requirements your databases has{' '}
+                the Advanced tab to successfully connect the database. Learn
+                what requirements your databases has{' '}
                 <a
                   href={DOCUMENTATION_LINK}
                   target="_blank"
@@ -372,24 +449,82 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
           />
         </Tabs.TabPane>
       </Tabs>
-    </StyledModal>
+    </Modal>
   ) : (
-    <StyledModal
+    <Modal
+      css={(theme: SupersetTheme) => [
+        antDModalNoPaddingStyles,
+        antDModalStyles(theme),
+        formHelperStyles(theme),
+        formStyles(theme),
+      ]}
       name="database"
-      className="database-modal"
       disablePrimaryButton={disableSave}
       height="600px"
       onHandledPrimaryAction={onSave}
       onHide={onClose}
-      primaryButtonName={hasConnectedDb ? t('Connect') : t('Finish')}
+      primaryButtonName={hasConnectedDb ? t('Finish') : t('Connect')}
       width="500px"
       show={show}
       title={<h4>{t('Connect a database')}</h4>}
     >
-      <div>
-        <p>TODO: db form</p>
-      </div>
-    </StyledModal>
+      {hasConnectedDb ? (
+        <ExtraOptions
+          db={db as DatabaseObject}
+          onInputChange={({ target }: { target: HTMLInputElement }) =>
+            onChange(ActionType.inputChange, {
+              type: target.type,
+              name: target.name,
+              checked: target.checked,
+              value: target.value,
+            })
+          }
+          onTextChange={({ target }: { target: HTMLTextAreaElement }) =>
+            onChange(ActionType.textChange, {
+              name: target.name,
+              value: target.value,
+            })
+          }
+          onEditorChange={(payload: { name: string; json: any }) =>
+            onChange(ActionType.editorChange, payload)
+          }
+        />
+      ) : (
+        <>
+          <DatabaseConnectionForm
+            dbModel={dbModel}
+            onParametersChange={({ target }: { target: HTMLInputElement }) =>
+              onChange(ActionType.parametersChange, {
+                type: target.type,
+                name: target.name,
+                checked: target.checked,
+                value: target.value,
+              })
+            }
+            onChange={({ target }: { target: HTMLInputElement }) =>
+              onChange(ActionType.textChange, {
+                name: target.name,
+                value: target.value,
+              })
+            }
+          />
+          <Button
+            buttonStyle="link"
+            onClick={() =>
+              setDB({
+                type: ActionType.configMethodChange,
+                payload: {
+                  configuration_method: CONFIGURATION_METHOD.SQLALCHEMY_URI,
+                },
+              })
+            }
+            css={buttonLinkStyles}
+          >
+            Connect this database with a SQLAlchemy URI string instead
+          </Button>
+        </>
+      )}
+    </Modal>
   );
 };
 
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts
index 38f5de7..8c33756 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts
@@ -17,8 +17,7 @@
  * under the License.
  */
 
-import { styled } from '@superset-ui/core';
-import Modal from 'src/components/Modal';
+import { styled, css, SupersetTheme } from '@superset-ui/core';
 import { JsonEditor } from 'src/components/AsyncAceEditor';
 import Tabs from 'src/components/Tabs';
 
@@ -28,76 +27,160 @@ const EXPOSE_ALL_FORM_HEIGHT = EXPOSE_IN_SQLLAB_FORM_HEIGHT + 102;
 
 const anticonHeight = 12;
 
-export const StyledModal = styled(Modal)`
-  .ant-collapse {
-    .ant-collapse-header {
-      padding-top: ${({ theme }) => theme.gridUnit * 3.5}px;
-      padding-bottom: ${({ theme }) => theme.gridUnit * 2.5}px;
+export const StyledFormHeader = styled.header`
+  border-bottom: ${({ theme }) => `${theme.gridUnit * 0.25}px solid
+    ${theme.colors.grayscale.light2};`}
+  padding-left: ${({ theme }) => theme.gridUnit * 4}px;
+  padding-right: ${({ theme }) => theme.gridUnit * 4}px;
+  margin-bottom: ${({ theme }) => theme.gridUnit * 4}px;
+  .helper {
+    color: ${({ theme }) => theme.colors.grayscale.base};
+    font-size: ${({ theme }) => theme.typography.sizes.s - 1}px;
+  }
+  h4 {
+    color: ${({ theme }) => theme.colors.grayscale.dark2};
+    font-weight: bold;
+    font-size: ${({ theme }) => theme.typography.sizes.l}px;
+  }
+`;
 
-      .anticon.ant-collapse-arrow {
-        top: calc(50% - ${anticonHeight / 2}px);
-      }
-      .helper {
-        color: ${({ theme }) => theme.colors.grayscale.base};
-      }
-    }
-    h4 {
-      font-size: 16px;
-      font-weight: bold;
-      margin-top: 0;
-      margin-bottom: ${({ theme }) => theme.gridUnit}px;
+export const antdCollapseStyles = (theme: SupersetTheme) => css`
+  .ant-collapse-header {
+    padding-top: ${theme.gridUnit * 3.5}px;
+    padding-bottom: ${theme.gridUnit * 2.5}px;
+
+    .anticon.ant-collapse-arrow {
+      top: calc(50% - ${anticonHeight / 2}px);
     }
-    p.helper {
-      margin-bottom: 0;
-      padding: 0;
+    .helper {
+      color: ${theme.colors.grayscale.base};
     }
   }
-  .ant-modal-header {
-    padding: 18px 16px 16px;
+  h4 {
+    font-size: 16px;
+    font-weight: bold;
+    margin-top: 0;
+    margin-bottom: ${theme.gridUnit}px;
+  }
+  p.helper {
+    margin-bottom: 0;
+    padding: 0;
   }
+`;
+
+export const antDTabsStyles = css`
+  .ant-tabs-top > .ant-tabs-nav {
+    margin-bottom: 0;
+  }
+  .ant-tabs-tab {
+    margin-right: 0;
+  }
+`;
+
+export const antDModalNoPaddingStyles = css`
   .ant-modal-body {
     padding-left: 0;
     padding-right: 0;
     margin-bottom: 110px;
   }
-  .ant-tabs-top > .ant-tabs-nav {
-    margin-bottom: 0;
+`;
+
+export const formScrollableStyles = (theme: SupersetTheme) => css`
+  overflow-y: scroll;
+  padding-left: ${theme.gridUnit * 4}px;
+  padding-right: ${theme.gridUnit * 4}px;
+`;
+
+export const antDModalStyles = (theme: SupersetTheme) => css`
+  .ant-modal-header {
+    padding: ${theme.gridUnit * 4.5}px ${theme.gridUnit * 4}px
+      ${theme.gridUnit * 4}px;
   }
+
   .ant-modal-close-x .close {
-    color: ${({ theme }) => theme.colors.grayscale.dark1};
+    color: ${theme.colors.grayscale.dark1};
     opacity: 1;
   }
+  .ant-modal-title > h4 {
+    font-weight: bold;
+  }
+`;
 
+export const antDAlertStyles = (theme: SupersetTheme) => css`
+  border: 1px solid ${theme.colors.info.base};
+  padding: ${theme.gridUnit * 4}px;
+  margin: ${theme.gridUnit * 8}px 0 0;
+  .ant-alert-message {
+    color: ${theme.colors.info.dark2};
+    font-size: ${theme.typography.sizes.s + 1}px;
+    font-weight: bold;
+  }
+  .ant-alert-description {
+    color: ${theme.colors.info.dark2};
+    font-size: ${theme.typography.sizes.s + 1}px;
+    line-height: ${theme.gridUnit * 4}px;
+    .ant-alert-icon {
+      margin-right: ${theme.gridUnit * 2.5}px;
+      font-size: ${theme.typography.sizes.l + 1}px;
+      position: relative;
+      top: ${theme.gridUnit / 4}px;
+    }
+  }
+`;
+
+export const formHelperStyles = (theme: SupersetTheme) => css`
   .required {
-    margin-left: ${({ theme }) => theme.gridUnit / 2}px;
-    color: ${({ theme }) => theme.colors.error.base};
+    margin-left: ${theme.gridUnit / 2}px;
+    color: ${theme.colors.error.base};
   }
 
   .helper {
     display: block;
-    padding: ${({ theme }) => theme.gridUnit}px 0;
-    color: ${({ theme }) => theme.colors.grayscale.light1};
-    font-size: ${({ theme }) => theme.typography.sizes.s - 1}px;
+    padding: ${theme.gridUnit}px 0;
+    color: ${theme.colors.grayscale.light1};
+    font-size: ${theme.typography.sizes.s - 1}px;
     text-align: left;
   }
-  .ant-modal-title > h4 {
-    font-weight: bold;
-  }
+`;
 
-  .ant-alert {
-    color: ${({ theme }) => theme.colors.info.dark2};
-    border: 1px solid ${({ theme }) => theme.colors.info.base};
-    font-size: ${({ theme }) => theme.gridUnit * 3}px;
-    padding: ${({ theme }) => theme.gridUnit * 4}px;
-    margin: ${({ theme }) => theme.gridUnit * 4}px 0 0;
+export const wideButton = (theme: SupersetTheme) => css`
+  width: 100%;
+  border: 1px solid ${theme.colors.primary.dark2};
+  color: ${theme.colors.primary.dark2};
+  &:hover,
+  &:focus {
+    border: 1px solid ${theme.colors.primary.dark1};
+    color: ${theme.colors.primary.dark1};
   }
-  .ant-alert-with-description {
-    .ant-alert-message,
-    .alert-with-description {
-      color: ${({ theme }) => theme.colors.info.dark2};
-      font-weight: bold;
+`;
+
+export const formStyles = (theme: SupersetTheme) => css`
+  .form-group {
+    margin-bottom: ${theme.gridUnit * 4}px;
+    &-w-50 {
+      display: inline-block;
+      width: ${`calc(50% - ${theme.gridUnit * 4}px)`};
+      & + .form-group-w-50 {
+        margin-left: ${theme.gridUnit * 8}px;
+      }
+    }
+    .text-danger {
+      color: ${theme.colors.error.base};
+      font-size: ${theme.typography.sizes.s - 1}px;
+      strong {
+        font-weight: normal;
+      }
     }
   }
+  .control-label {
+    color: ${theme.colors.grayscale.dark1};
+    font-size: ${theme.typography.sizes.s - 1}px;
+  }
+  .helper {
+    color: ${theme.colors.grayscale.light1};
+    font-size: ${theme.typography.sizes.s - 1}px;
+    margin-top: ${theme.gridUnit * 1.5}px;
+  }
   .ant-modal-body {
     padding-top: 0;
     margin-bottom: 0;
@@ -219,7 +302,12 @@ export const StyledExpandableForm = styled.div`
 export const StyledBasicTab = styled(Tabs.TabPane)`
   padding-left: ${({ theme }) => theme.gridUnit * 4}px;
   padding-right: ${({ theme }) => theme.gridUnit * 4}px;
-  margin-top: ${({ theme }) => theme.gridUnit * 4}px;
+  margin-top: ${({ theme }) => theme.gridUnit * 6}px;
+`;
+
+export const buttonLinkStyles = css`
+  font-weight: 400;
+  text-transform: initial;
 `;
 
 export const EditHeader = styled.div`
@@ -237,22 +325,20 @@ export const CreateHeader = styled.div`
   flex-direction: column;
   justify-content: center;
   padding: 0px;
-  margin: ${({ theme }) => theme.gridUnit * 4}px
-    ${({ theme }) => theme.gridUnit * 4}px
-    ${({ theme }) => theme.gridUnit * 9}px;
+  margin: 0 ${({ theme }) => theme.gridUnit * 4}px
+    ${({ theme }) => theme.gridUnit * 6}px;
 `;
 
 export const CreateHeaderTitle = styled.div`
-  color: ${({ theme }) => theme.colors.grayscale.dark1};
+  color: ${({ theme }) => theme.colors.grayscale.dark2};
   font-weight: bold;
-  font-size: ${({ theme }) => theme.typography.sizes.l}px;
-  padding: ${({ theme }) => theme.gridUnit * 1}px;
+  font-size: ${({ theme }) => theme.typography.sizes.m}px;
+  padding: ${({ theme }) => theme.gridUnit * 1}px 0;
 `;
 
 export const CreateHeaderSubtitle = styled.div`
   color: ${({ theme }) => theme.colors.grayscale.dark1};
   font-size: ${({ theme }) => theme.typography.sizes.s}px;
-  padding: ${({ theme }) => theme.gridUnit * 1}px;
 `;
 
 export const EditHeaderTitle = styled.div`
@@ -266,7 +352,3 @@ export const EditHeaderSubtitle = styled.div`
   font-size: ${({ theme }) => theme.typography.sizes.xl}px;
   font-weight: bold;
 `;
-
-export const Divider = styled.hr`
-  border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light1};
-`;
diff --git a/superset-frontend/src/views/CRUD/data/database/types.ts b/superset-frontend/src/views/CRUD/data/database/types.ts
index d0e6f51..2c386b5 100644
--- a/superset-frontend/src/views/CRUD/data/database/types.ts
+++ b/superset-frontend/src/views/CRUD/data/database/types.ts
@@ -30,6 +30,8 @@ export type DatabaseObject = {
   created_by?: null | DatabaseUser;
   changed_on_delta_humanized?: string;
   changed_on?: string;
+  parameters?: { database_name?: string; engine?: string };
+  configuration_method: CONFIGURATION_METHOD;
 
   // Performance
   cache_timeout?: string;
@@ -52,3 +54,51 @@ export type DatabaseObject = {
   allow_csv_upload?: boolean;
   extra?: string;
 };
+
+export type DatabaseForm = {
+  engine: string;
+  name: string;
+  parameters: {
+    properties: {
+      database: {
+        description: string;
+        type: string;
+      };
+      host: {
+        description: string;
+        type: string;
+      };
+      password: {
+        description: string;
+        nullable: boolean;
+        type: string;
+      };
+      port: {
+        description: string;
+        format: string;
+        type: string;
+      };
+      query: {
+        additionalProperties: {};
+        description: string;
+        type: string;
+      };
+      username: {
+        description: string;
+        nullable: boolean;
+        type: string;
+      };
+    };
+    required: string[];
+    type: string;
+  };
+  preferred: boolean;
+  sqlalchemy_uri_placeholder: string;
+};
+
+// the values should align with the database
+// model enum in superset/superset/models/core.py
+export enum CONFIGURATION_METHOD {
+  SQLALCHEMY_URI = 'sqlalchemy_form',
+  DYNAMIC_FORM = 'dynamic_form',
+}
diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts
index 9ec1104..4275499 100644
--- a/superset-frontend/src/views/CRUD/hooks.ts
+++ b/superset-frontend/src/views/CRUD/hooks.ts
@@ -18,7 +18,7 @@
  */
 import rison from 'rison';
 import { useState, useEffect, useCallback } from 'react';
-import { makeApi, SupersetClient, t } from '@superset-ui/core';
+import { makeApi, SupersetClient, t, JsonObject } from '@superset-ui/core';
 
 import { createErrorHandler } from 'src/views/CRUD/utils';
 import { FetchDataConfig } from 'src/components/ListView';
@@ -277,7 +277,7 @@ export function useSingleViewResource<D extends object = any>(
         .then(
           ({ json = {} }) => {
             updateState({
-              resource: json.result,
+              resource: { id: json.id, ...json.result },
               error: null,
             });
             return json.id;
@@ -643,3 +643,17 @@ export const testDatabaseConnection = (
     }),
   );
 };
+
+export function useAvailableDatabases() {
+  const [availableDbs, setAvailableDbs] = useState<JsonObject | null>(null);
+
+  const getAvailable = useCallback(() => {
+    SupersetClient.get({
+      endpoint: `/api/v1/database/available`,
+    }).then(({ json }) => {
+      setAvailableDbs(json);
+    });
+  }, [setAvailableDbs]);
+
+  return [availableDbs, getAvailable] as const;
+}
diff --git a/superset/databases/schemas.py b/superset/databases/schemas.py
index e43997d..347c939 100644
--- a/superset/databases/schemas.py
+++ b/superset/databases/schemas.py
@@ -39,6 +39,7 @@ database_schemas_query_schema = {
 }
 
 database_name_description = "A database name to identify this connection."
+port_description = "Port number for the database connection."
 cache_timeout_description = (
     "Duration (in seconds) of the caching timeout for charts of this database. "
     "A timeout of 0 indicates that the cache never expires. "