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. "