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:29:30 UTC
[superset] 03/05: hook up available databases
This is an automated email from the ASF dual-hosted git repository.
hugh pushed a commit to branch hugh-validation-db-modal-bg
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 83a4d46a93984724be78a9503a6dc86aba933ff2
Author: Elizabeth Thompson <es...@gmail.com>
AuthorDate: Tue Apr 27 17:08:49 2021 -0700
hook up available databases
---
.../src/views/CRUD/data/database/DatabaseList.tsx | 2 +-
.../DatabaseModal/DatabaseConnectionForm.tsx | 158 ++++++++++++++++
.../data/database/DatabaseModal/ExtraOptions.tsx | 11 +-
.../data/database/DatabaseModal/SqlAlchemyForm.tsx | 8 +-
.../data/database/DatabaseModal/index.test.jsx | 84 ++++++++-
.../CRUD/data/database/DatabaseModal/index.tsx | 207 +++++++++++++++++----
.../CRUD/data/database/DatabaseModal/styles.ts | 140 ++++++++++----
.../src/views/CRUD/data/database/types.ts | 48 +++++
superset-frontend/src/views/CRUD/hooks.ts | 18 +-
superset/databases/schemas.py | 1 +
10 files changed, 596 insertions(+), 81 deletions(-)
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
index 4652275..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;
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..c8d98d7
--- /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 { FormGroup, FormControl } from 'react-bootstrap';
+import FormLabel from 'src/components/Form/FormLabel';
+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<FormControl> | { target: HTMLInputElement },
+ ) => void;
+ onChange: (
+ event: FormEvent<FormControl> | { 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 (
+ <FormGroup
+ className={cx(className, `form-group-${className}`)}
+ key={field}
+ >
+ <FormLabel
+ htmlFor={field}
+ required={parameters.required.includes(field)}
+ >
+ {description}
+ </FormLabel>
+ <FormControl
+ name={field}
+ type={type}
+ id={field}
+ bsSize="sm"
+ autoComplete="off"
+ placeholder={placeholder}
+ onChange={onEdit}
+ />
+ <p className="helper">{label}</p>
+ </FormGroup>
+ );
+ })}
+ </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..d4c6278 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/SqlAlchemyForm.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/SqlAlchemyForm.tsx
@@ -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,15 +71,15 @@ 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
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..f47214c 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,15 +26,24 @@ 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,
@@ -45,6 +54,13 @@ import {
CreateHeaderSubtitle,
CreateHeaderTitle,
Divider,
+ StyledBasicTab,
+ antDModalStyles,
+ antDModalNoPaddingStyles,
+ formHelperStyles,
+ formStyles,
+ antDTabsStyles,
+ buttonLinkStyles,
} from './styles';
const DOCUMENTATION_LINK =
@@ -65,6 +81,9 @@ enum ActionType {
editorChange,
fetched,
reset,
+ dbSelected,
+ parametersChange,
+ configMethodChange,
}
interface DBReducerPayloadType {
@@ -81,7 +100,8 @@ type DBReducerActionType =
type:
| ActionType.textChange
| ActionType.inputChange
- | ActionType.editorChange;
+ | ActionType.editorChange
+ | ActionType.parametersChange;
payload: DBReducerPayloadType;
}
| {
@@ -89,7 +109,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 +145,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 +165,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 +183,7 @@ function dbReducer(
}
const DEFAULT_TAB_KEY = '1';
+const FALSY_FORM_VALUES = [undefined, null, ''];
const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
addDangerToast,
@@ -148,11 +197,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 +238,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 +294,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,7 +309,6 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
}, [show, databaseId]);
useEffect(() => {
- // TODO: can we include these values in the original fetch?
if (dbFetched) {
setDB({
type: ActionType.fetched,
@@ -266,10 +323,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}
@@ -325,7 +404,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
/>
) : (
<div>
- <p>TODO: db form</p>
+ <p>TODO: form</p>
</div>
)}
<Alert
@@ -372,24 +451,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..638b06b 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,60 +27,126 @@ 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 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;
+`;
+
+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-alert {
@@ -219,7 +284,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`
diff --git a/superset-frontend/src/views/CRUD/data/database/types.ts b/superset-frontend/src/views/CRUD/data/database/types.ts
index d0e6f51..920f4b6 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,49 @@ 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;
+};
+
+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 a23659f..924ac94 100644
--- a/superset/databases/schemas.py
+++ b/superset/databases/schemas.py
@@ -40,6 +40,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. "