feat: save database with new dynamic form (#14583)

* split db modal file

* split db modal file

* hook up available databases

* add comment
This commit is contained in:
Elizabeth Thompson 2021-05-21 15:25:56 -07:00 committed by GitHub
parent dd318539fa
commit c7aee4e27b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 666 additions and 139 deletions

View File

@ -29,8 +29,8 @@ import { Tooltip } from 'src/components/Tooltip';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import ListView, { FilterOperator, Filters } from 'src/components/ListView'; import ListView, { FilterOperator, Filters } from 'src/components/ListView';
import { commonMenuData } from 'src/views/CRUD/data/common'; 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 ImportModelsModal from 'src/components/ImportModal/index';
import DatabaseModal from './DatabaseModal';
import { DatabaseObject } from './types'; import { DatabaseObject } from './types';
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@ -147,10 +147,13 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
); );
} }
function handleDatabaseEdit(database: DatabaseObject) { function handleDatabaseEditModal({
// Set database and open modal database = null,
modalOpen = false,
}: { database?: DatabaseObject | null; modalOpen?: boolean } = {}) {
// Set database and modal
setCurrentDatabase(database); setCurrentDatabase(database);
setDatabaseModalOpen(true); setDatabaseModalOpen(modalOpen);
} }
const canCreate = hasPerm('can_write'); const canCreate = hasPerm('can_write');
@ -176,8 +179,7 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
buttonStyle: 'primary', buttonStyle: 'primary',
onClick: () => { onClick: () => {
// Ensure modal will be opened in add mode // Ensure modal will be opened in add mode
setCurrentDatabase(null); handleDatabaseEditModal({ modalOpen: true });
setDatabaseModalOpen(true);
}, },
}, },
]; ];
@ -298,7 +300,8 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
}, },
{ {
Cell: ({ row: { original } }: any) => { Cell: ({ row: { original } }: any) => {
const handleEdit = () => handleDatabaseEdit(original); const handleEdit = () =>
handleDatabaseEditModal({ database: original, modalOpen: true });
const handleDelete = () => openDatabaseDeleteModal(original); const handleDelete = () => openDatabaseDeleteModal(original);
const handleExport = () => handleDatabaseExport(original); const handleExport = () => handleDatabaseExport(original);
if (!canEdit && !canDelete && !canExport) { if (!canEdit && !canDelete && !canExport) {
@ -416,7 +419,7 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
<DatabaseModal <DatabaseModal
databaseId={currentDatabase?.id} databaseId={currentDatabase?.id}
show={databaseModalOpen} show={databaseModalOpen}
onHide={() => setDatabaseModalOpen(false)} onHide={handleDatabaseEditModal}
onDatabaseAdd={() => { onDatabaseAdd={() => {
refreshData(); refreshData();
}} }}

View File

@ -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;

View File

@ -18,7 +18,7 @@
*/ */
import React, { ChangeEvent, EventHandler } from 'react'; import React, { ChangeEvent, EventHandler } from 'react';
import cx from 'classnames'; import cx from 'classnames';
import { t } from '@superset-ui/core'; import { t, SupersetTheme } from '@superset-ui/core';
import InfoTooltip from 'src/components/InfoTooltip'; import InfoTooltip from 'src/components/InfoTooltip';
import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
import Collapse from 'src/components/Collapse'; import Collapse from 'src/components/Collapse';
@ -26,7 +26,8 @@ import {
StyledInputContainer, StyledInputContainer,
StyledJsonEditor, StyledJsonEditor,
StyledExpandableForm, StyledExpandableForm,
} from 'src/views/CRUD/data/database/DatabaseModal/styles'; antdCollapseStyles,
} from './styles';
import { DatabaseObject } from '../types'; import { DatabaseObject } from '../types';
const defaultExtra = const defaultExtra =
@ -48,7 +49,11 @@ const ExtraOptions = ({
const createAsOpen = !!(db?.allow_ctas || db?.allow_cvas); const createAsOpen = !!(db?.allow_ctas || db?.allow_cvas);
return ( return (
<Collapse expandIconPosition="right" accordion> <Collapse
expandIconPosition="right"
accordion
css={(theme: SupersetTheme) => antdCollapseStyles(theme)}
>
<Collapse.Panel <Collapse.Panel
header={ header={
<div> <div>

View File

@ -17,9 +17,9 @@
* under the License. * under the License.
*/ */
import React, { EventHandler, ChangeEvent, MouseEvent } from 'react'; 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 Button from 'src/components/Button';
import { StyledInputContainer } from './styles'; import { StyledInputContainer, wideButton } from './styles';
import { DatabaseObject } from '../types'; import { DatabaseObject } from '../types';
@ -45,7 +45,7 @@ const SqlAlchemyTab = ({
type="text" type="text"
name="database_name" name="database_name"
value={db?.database_name || ''} value={db?.database_name || ''}
placeholder={t('Name your dataset')} placeholder={t('Name your database')}
onChange={onInputChange} onChange={onInputChange}
/> />
</div> </div>
@ -71,25 +71,22 @@ const SqlAlchemyTab = ({
/> />
</div> </div>
<div className="helper"> <div className="helper">
{t('Refer to the ')} {t('Refer to the')}{' '}
<a <a
href={conf?.SQLALCHEMY_DOCS_URL ?? ''} href={conf?.SQLALCHEMY_DOCS_URL ?? ''}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{conf?.SQLALCHEMY_DISPLAY_TEXT ?? ''} {conf?.SQLALCHEMY_DISPLAY_TEXT ?? ''}
</a> </a>{' '}
{t(' for more information on how to structure your URI.')} {t('for more information on how to structure your URI.')}
</div> </div>
</StyledInputContainer> </StyledInputContainer>
<Button <Button
onClick={testConnection} onClick={testConnection}
cta cta
buttonStyle="link" buttonStyle="link"
style={{ css={(theme: SupersetTheme) => wideButton(theme)}
width: '100%',
border: `1px solid ${supersetTheme.colors.primary.base}`,
}}
> >
{t('Test connection')} {t('Test connection')}
</Button> </Button>

View File

@ -42,20 +42,35 @@ const mockedProps = {
const dbProps = { const dbProps = {
show: true, show: true,
databaseId: 10, databaseId: 10,
database_name: 'my database',
sqlalchemy_uri: 'postgres://superset:superset@something:1234/superset',
}; };
const DATABASE_ENDPOINT = 'glob:*/api/v1/database/*'; const DATABASE_ENDPOINT = 'glob:*/api/v1/database/*';
const AVAILABLE_DB_ENDPOINT = 'glob:*/api/v1/database/available/*';
fetchMock.config.overwriteRoutes = true;
fetchMock.get(DATABASE_ENDPOINT, { fetchMock.get(DATABASE_ENDPOINT, {
result: { result: {
id: 1, id: 10,
database_name: 'my database', database_name: 'my database',
expose_in_sqllab: false, expose_in_sqllab: false,
allow_ctas: false, allow_ctas: false,
allow_cvas: false, allow_cvas: false,
configuration_method: 'sqlalchemy_form',
}, },
}); });
fetchMock.get(AVAILABLE_DB_ENDPOINT, {
databases: [
{
engine: 'mysql',
name: 'MySQL',
preferred: false,
},
],
});
describe('DatabaseModal', () => { describe('DatabaseModal', () => {
afterEach(fetchMock.reset);
describe('enzyme', () => { describe('enzyme', () => {
let wrapper; let wrapper;
let spyOnUseSelector; let spyOnUseSelector;
@ -251,5 +266,72 @@ describe('DatabaseModal', () => {
// Both checkboxes go unchecked, so the field should no longer render // Both checkboxes go unchecked, so the field should no longer render
expect(schemaField).not.toHaveClass('open'); 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();
});
});
}); });
}); });

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { t } from '@superset-ui/core'; import { t, SupersetTheme } from '@superset-ui/core';
import React, { import React, {
FunctionComponent, FunctionComponent,
useEffect, useEffect,
@ -26,25 +26,39 @@ import React, {
} from 'react'; } from 'react';
import Tabs from 'src/components/Tabs'; import Tabs from 'src/components/Tabs';
import { Alert } from 'src/common/components'; 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 withToasts from 'src/messageToasts/enhancers/withToasts';
import { import {
testDatabaseConnection, testDatabaseConnection,
useSingleViewResource, useSingleViewResource,
useAvailableDatabases,
} from 'src/views/CRUD/hooks'; } from 'src/views/CRUD/hooks';
import { useCommonConf } from 'src/views/CRUD/data/database/state'; 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 ExtraOptions from './ExtraOptions';
import SqlAlchemyForm from './SqlAlchemyForm'; import SqlAlchemyForm from './SqlAlchemyForm';
import DatabaseConnectionForm from './DatabaseConnectionForm';
import { import {
StyledBasicTab, antDAlertStyles,
StyledModal, antDModalNoPaddingStyles,
EditHeader, antDModalStyles,
EditHeaderTitle, antDTabsStyles,
EditHeaderSubtitle, buttonLinkStyles,
CreateHeader, CreateHeader,
CreateHeaderSubtitle, CreateHeaderSubtitle,
CreateHeaderTitle, CreateHeaderTitle,
Divider, EditHeader,
EditHeaderSubtitle,
EditHeaderTitle,
formHelperStyles,
formStyles,
StyledBasicTab,
} from './styles'; } from './styles';
const DOCUMENTATION_LINK = const DOCUMENTATION_LINK =
@ -60,11 +74,14 @@ interface DatabaseModalProps {
} }
enum ActionType { enum ActionType {
textChange, configMethodChange,
inputChange, dbSelected,
editorChange, editorChange,
fetched, fetched,
inputChange,
parametersChange,
reset, reset,
textChange,
} }
interface DBReducerPayloadType { interface DBReducerPayloadType {
@ -81,15 +98,27 @@ type DBReducerActionType =
type: type:
| ActionType.textChange | ActionType.textChange
| ActionType.inputChange | ActionType.inputChange
| ActionType.editorChange; | ActionType.editorChange
| ActionType.parametersChange;
payload: DBReducerPayloadType; payload: DBReducerPayloadType;
} }
| { | {
type: ActionType.fetched; type: ActionType.fetched;
payload: Partial<DatabaseObject>; payload: Partial<DatabaseObject>;
} }
| {
type: ActionType.dbSelected;
payload: {
parameters: { engine?: string };
configuration_method: CONFIGURATION_METHOD;
};
}
| { | {
type: ActionType.reset; type: ActionType.reset;
}
| {
type: ActionType.configMethodChange;
payload: { configuration_method: CONFIGURATION_METHOD };
}; };
function dbReducer( function dbReducer(
@ -114,6 +143,14 @@ function dbReducer(
...trimmedState, ...trimmedState,
[action.payload.name]: action.payload.value, [action.payload.name]: action.payload.value,
}; };
case ActionType.parametersChange:
return {
...trimmedState,
parameters: {
...trimmedState.parameters,
[action.payload.name]: action.payload.value,
},
};
case ActionType.editorChange: case ActionType.editorChange:
return { return {
...trimmedState, ...trimmedState,
@ -125,6 +162,15 @@ function dbReducer(
[action.payload.name]: action.payload.value, [action.payload.name]: action.payload.value,
}; };
case ActionType.fetched: case ActionType.fetched:
return {
parameters: {
engine: trimmedState.parameters?.engine,
},
configuration_method: trimmedState.configuration_method,
...action.payload,
};
case ActionType.dbSelected:
case ActionType.configMethodChange:
return { return {
...action.payload, ...action.payload,
}; };
@ -135,6 +181,7 @@ function dbReducer(
} }
const DEFAULT_TAB_KEY = '1'; const DEFAULT_TAB_KEY = '1';
const FALSY_FORM_VALUES = [undefined, null, ''];
const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
addDangerToast, addDangerToast,
@ -148,11 +195,13 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
Reducer<Partial<DatabaseObject> | null, DBReducerActionType> Reducer<Partial<DatabaseObject> | null, DBReducerActionType>
>(dbReducer, null); >(dbReducer, null);
const [tabKey, setTabKey] = useState<string>(DEFAULT_TAB_KEY); const [tabKey, setTabKey] = useState<string>(DEFAULT_TAB_KEY);
const [availableDbs, getAvailableDbs] = useAvailableDatabases();
const [hasConnectedDb, setHasConnectedDb] = useState<boolean>(false);
const conf = useCommonConf(); const conf = useCommonConf();
const isEditMode = !!databaseId; const isEditMode = !!databaseId;
const useSqlAlchemyForm = true; // TODO: set up logic const useSqlAlchemyForm =
const hasConnectedDb = false; // TODO: set up logic db?.configuration_method === CONFIGURATION_METHOD.SQLALCHEMY_URI;
// Database fetch logic // Database fetch logic
const { const {
@ -187,40 +236,39 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
const onClose = () => { const onClose = () => {
setDB({ type: ActionType.reset }); setDB({ type: ActionType.reset });
setHasConnectedDb(false);
onHide(); onHide();
}; };
const onSave = () => { const onSave = () => {
if (isEditMode) { // eslint-disable-next-line @typescript-eslint/no-unused-vars
// databaseId will not be null if isEditMode is true const { id, ...update } = db || {};
// db will have at least a database_name and sqlalchemy_uri if (db?.id) {
// in order for the button to not be disabled if (db.sqlalchemy_uri) {
updateResource(databaseId as number, db as DatabaseObject).then( // don't pass parameters if using the sqlalchemy uri
result => { delete update.parameters;
if (result) { }
if (onDatabaseAdd) { updateResource(db.id as number, update as DatabaseObject).then(result => {
onDatabaseAdd(); if (result) {
}
onClose();
}
},
);
} else if (db) {
// Create
db.database_name = db?.database_name?.trim();
createResource(db as DatabaseObject).then(dbId => {
if (dbId) {
if (onDatabaseAdd) { if (onDatabaseAdd) {
onDatabaseAdd(); onDatabaseAdd();
} }
onClose(); onClose();
} }
}); });
} else if (db) {
// Create
createResource(update as DatabaseObject).then(dbId => {
if (dbId) {
setHasConnectedDb(true);
if (onDatabaseAdd) {
onDatabaseAdd();
}
}
});
} }
}; };
const disableSave = !(db?.database_name?.trim() && db?.sqlalchemy_uri);
const onChange = (type: any, payload: any) => { const onChange = (type: any, payload: any) => {
setDB({ type, payload } as DBReducerActionType); setDB({ type, payload } as DBReducerActionType);
}; };
@ -244,6 +292,14 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
useEffect(() => { useEffect(() => {
if (show) { if (show) {
setTabKey(DEFAULT_TAB_KEY); 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) { if (databaseId && show) {
fetchDB(); fetchDB();
@ -251,13 +307,10 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
}, [show, databaseId]); }, [show, databaseId]);
useEffect(() => { useEffect(() => {
// TODO: can we include these values in the original fetch?
if (dbFetched) { if (dbFetched) {
setDB({ setDB({
type: ActionType.fetched, type: ActionType.fetched,
payload: { payload: dbFetched,
...dbFetched,
},
}); });
} }
}, [dbFetched]); }, [dbFetched]);
@ -266,10 +319,32 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
setTabKey(key); 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 ? ( return isEditMode || useSqlAlchemyForm ? (
<StyledModal <Modal
css={(theme: SupersetTheme) => [
antDTabsStyles,
antDModalStyles(theme),
antDModalNoPaddingStyles,
formHelperStyles(theme),
]}
name="database" name="database"
className="database-modal"
disablePrimaryButton={disableSave} disablePrimaryButton={disableSave}
height="600px" height="600px"
onHandledPrimaryAction={onSave} onHandledPrimaryAction={onSave}
@ -302,11 +377,12 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
</CreateHeaderSubtitle> </CreateHeaderSubtitle>
</CreateHeader> </CreateHeader>
)} )}
<Divider /> <hr />
<Tabs <Tabs
defaultActiveKey={DEFAULT_TAB_KEY} defaultActiveKey={DEFAULT_TAB_KEY}
activeKey={tabKey} activeKey={tabKey}
onTabClick={tabChange} onTabClick={tabChange}
animated={{ inkBar: true, tabPane: true }}
> >
<StyledBasicTab tab={<span>{t('Basic')}</span>} key="1"> <StyledBasicTab tab={<span>{t('Basic')}</span>} key="1">
{useSqlAlchemyForm ? ( {useSqlAlchemyForm ? (
@ -325,16 +401,17 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
/> />
) : ( ) : (
<div> <div>
<p>TODO: db form</p> <p>TODO: form</p>
</div> </div>
)} )}
<Alert <Alert
css={(theme: SupersetTheme) => antDAlertStyles(theme)}
message="Additional fields may be required" message="Additional fields may be required"
description={ description={
<> <>
Select databases require additional fields to be completed in Select databases require additional fields to be completed in
the next step to successfully connect the database. Learn what the Advanced tab to successfully connect the database. Learn
requirements your databases has{' '} what requirements your databases has{' '}
<a <a
href={DOCUMENTATION_LINK} href={DOCUMENTATION_LINK}
target="_blank" target="_blank"
@ -372,24 +449,82 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
/> />
</Tabs.TabPane> </Tabs.TabPane>
</Tabs> </Tabs>
</StyledModal> </Modal>
) : ( ) : (
<StyledModal <Modal
css={(theme: SupersetTheme) => [
antDModalNoPaddingStyles,
antDModalStyles(theme),
formHelperStyles(theme),
formStyles(theme),
]}
name="database" name="database"
className="database-modal"
disablePrimaryButton={disableSave} disablePrimaryButton={disableSave}
height="600px" height="600px"
onHandledPrimaryAction={onSave} onHandledPrimaryAction={onSave}
onHide={onClose} onHide={onClose}
primaryButtonName={hasConnectedDb ? t('Connect') : t('Finish')} primaryButtonName={hasConnectedDb ? t('Finish') : t('Connect')}
width="500px" width="500px"
show={show} show={show}
title={<h4>{t('Connect a database')}</h4>} title={<h4>{t('Connect a database')}</h4>}
> >
<div> {hasConnectedDb ? (
<p>TODO: db form</p> <ExtraOptions
</div> db={db as DatabaseObject}
</StyledModal> 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>
); );
}; };

View File

@ -17,8 +17,7 @@
* under the License. * under the License.
*/ */
import { styled } from '@superset-ui/core'; import { styled, css, SupersetTheme } from '@superset-ui/core';
import Modal from 'src/components/Modal';
import { JsonEditor } from 'src/components/AsyncAceEditor'; import { JsonEditor } from 'src/components/AsyncAceEditor';
import Tabs from 'src/components/Tabs'; import Tabs from 'src/components/Tabs';
@ -28,76 +27,160 @@ const EXPOSE_ALL_FORM_HEIGHT = EXPOSE_IN_SQLLAB_FORM_HEIGHT + 102;
const anticonHeight = 12; const anticonHeight = 12;
export const StyledModal = styled(Modal)` export const StyledFormHeader = styled.header`
.ant-collapse { border-bottom: ${({ theme }) => `${theme.gridUnit * 0.25}px solid
.ant-collapse-header { ${theme.colors.grayscale.light2};`}
padding-top: ${({ theme }) => theme.gridUnit * 3.5}px; padding-left: ${({ theme }) => theme.gridUnit * 4}px;
padding-bottom: ${({ theme }) => theme.gridUnit * 2.5}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 { export const antdCollapseStyles = (theme: SupersetTheme) => css`
top: calc(50% - ${anticonHeight / 2}px); .ant-collapse-header {
} padding-top: ${theme.gridUnit * 3.5}px;
.helper { padding-bottom: ${theme.gridUnit * 2.5}px;
color: ${({ theme }) => theme.colors.grayscale.base};
} .anticon.ant-collapse-arrow {
top: calc(50% - ${anticonHeight / 2}px);
} }
h4 { .helper {
font-size: 16px; color: ${theme.colors.grayscale.base};
font-weight: bold;
margin-top: 0;
margin-bottom: ${({ theme }) => theme.gridUnit}px;
}
p.helper {
margin-bottom: 0;
padding: 0;
} }
} }
.ant-modal-header { h4 {
padding: 18px 16px 16px; 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 { .ant-modal-body {
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
margin-bottom: 110px; 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 { .ant-modal-close-x .close {
color: ${({ theme }) => theme.colors.grayscale.dark1}; color: ${theme.colors.grayscale.dark1};
opacity: 1; opacity: 1;
} }
.required {
margin-left: ${({ theme }) => theme.gridUnit / 2}px;
color: ${({ theme }) => 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;
text-align: left;
}
.ant-modal-title > h4 { .ant-modal-title > h4 {
font-weight: bold; font-weight: bold;
} }
`;
.ant-alert { export const antDAlertStyles = (theme: SupersetTheme) => css`
color: ${({ theme }) => theme.colors.info.dark2}; border: 1px solid ${theme.colors.info.base};
border: 1px solid ${({ theme }) => theme.colors.info.base}; padding: ${theme.gridUnit * 4}px;
font-size: ${({ theme }) => theme.gridUnit * 3}px; margin: ${theme.gridUnit * 8}px 0 0;
padding: ${({ theme }) => theme.gridUnit * 4}px; .ant-alert-message {
margin: ${({ theme }) => theme.gridUnit * 4}px 0 0; color: ${theme.colors.info.dark2};
font-size: ${theme.typography.sizes.s + 1}px;
font-weight: bold;
} }
.ant-alert-with-description { .ant-alert-description {
.ant-alert-message, color: ${theme.colors.info.dark2};
.alert-with-description { font-size: ${theme.typography.sizes.s + 1}px;
color: ${({ theme }) => theme.colors.info.dark2}; line-height: ${theme.gridUnit * 4}px;
font-weight: bold; .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.gridUnit / 2}px;
color: ${theme.colors.error.base};
}
.helper {
display: block;
padding: ${theme.gridUnit}px 0;
color: ${theme.colors.grayscale.light1};
font-size: ${theme.typography.sizes.s - 1}px;
text-align: left;
}
`;
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};
}
`;
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 { .ant-modal-body {
padding-top: 0; padding-top: 0;
margin-bottom: 0; margin-bottom: 0;
@ -219,7 +302,12 @@ export const StyledExpandableForm = styled.div`
export const StyledBasicTab = styled(Tabs.TabPane)` export const StyledBasicTab = styled(Tabs.TabPane)`
padding-left: ${({ theme }) => theme.gridUnit * 4}px; padding-left: ${({ theme }) => theme.gridUnit * 4}px;
padding-right: ${({ 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` export const EditHeader = styled.div`
@ -237,22 +325,20 @@ export const CreateHeader = styled.div`
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
padding: 0px; padding: 0px;
margin: ${({ theme }) => theme.gridUnit * 4}px margin: 0 ${({ theme }) => theme.gridUnit * 4}px
${({ theme }) => theme.gridUnit * 4}px ${({ theme }) => theme.gridUnit * 6}px;
${({ theme }) => theme.gridUnit * 9}px;
`; `;
export const CreateHeaderTitle = styled.div` export const CreateHeaderTitle = styled.div`
color: ${({ theme }) => theme.colors.grayscale.dark1}; color: ${({ theme }) => theme.colors.grayscale.dark2};
font-weight: bold; font-weight: bold;
font-size: ${({ theme }) => theme.typography.sizes.l}px; font-size: ${({ theme }) => theme.typography.sizes.m}px;
padding: ${({ theme }) => theme.gridUnit * 1}px; padding: ${({ theme }) => theme.gridUnit * 1}px 0;
`; `;
export const CreateHeaderSubtitle = styled.div` export const CreateHeaderSubtitle = styled.div`
color: ${({ theme }) => theme.colors.grayscale.dark1}; color: ${({ theme }) => theme.colors.grayscale.dark1};
font-size: ${({ theme }) => theme.typography.sizes.s}px; font-size: ${({ theme }) => theme.typography.sizes.s}px;
padding: ${({ theme }) => theme.gridUnit * 1}px;
`; `;
export const EditHeaderTitle = styled.div` export const EditHeaderTitle = styled.div`
@ -266,7 +352,3 @@ export const EditHeaderSubtitle = styled.div`
font-size: ${({ theme }) => theme.typography.sizes.xl}px; font-size: ${({ theme }) => theme.typography.sizes.xl}px;
font-weight: bold; font-weight: bold;
`; `;
export const Divider = styled.hr`
border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light1};
`;

View File

@ -30,6 +30,8 @@ export type DatabaseObject = {
created_by?: null | DatabaseUser; created_by?: null | DatabaseUser;
changed_on_delta_humanized?: string; changed_on_delta_humanized?: string;
changed_on?: string; changed_on?: string;
parameters?: { database_name?: string; engine?: string };
configuration_method: CONFIGURATION_METHOD;
// Performance // Performance
cache_timeout?: string; cache_timeout?: string;
@ -52,3 +54,51 @@ export type DatabaseObject = {
allow_csv_upload?: boolean; allow_csv_upload?: boolean;
extra?: string; 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',
}

View File

@ -18,7 +18,7 @@
*/ */
import rison from 'rison'; import rison from 'rison';
import { useState, useEffect, useCallback } from 'react'; 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 { createErrorHandler } from 'src/views/CRUD/utils';
import { FetchDataConfig } from 'src/components/ListView'; import { FetchDataConfig } from 'src/components/ListView';
@ -277,7 +277,7 @@ export function useSingleViewResource<D extends object = any>(
.then( .then(
({ json = {} }) => { ({ json = {} }) => {
updateState({ updateState({
resource: json.result, resource: { id: json.id, ...json.result },
error: null, error: null,
}); });
return json.id; 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;
}

View File

@ -39,6 +39,7 @@ database_schemas_query_schema = {
} }
database_name_description = "A database name to identify this connection." database_name_description = "A database name to identify this connection."
port_description = "Port number for the database connection."
cache_timeout_description = ( cache_timeout_description = (
"Duration (in seconds) of the caching timeout for charts of this database. " "Duration (in seconds) of the caching timeout for charts of this database. "
"A timeout of 0 indicates that the cache never expires. " "A timeout of 0 indicates that the cache never expires. "