diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx index 51808dffed045..d1105e7062229 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) { 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 0000000000000..f0542e481c121 --- /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 | { target: HTMLInputElement }, + ) => void; + onChange: ( + event: FormEvent | { target: HTMLInputElement }, + ) => void; +}) => ( + <> + +

Enter the required {name} credentials

+

+ Need help? Learn more about connecting to {name}. +

+
+
+ {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 ( + + + {description} + + +

{label}

+
+ ); + })} +
+ +); + +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 8f005f9411ea2..e3987ad7ea7f1 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 ( - + antdCollapseStyles(theme)} + > 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 0bc2e0c816e0b..cf442b4a57e2b 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} /> @@ -71,25 +71,22 @@ const SqlAlchemyTab = ({ />
- {t('Refer to the ')} + {t('Refer to the')}{' '} {conf?.SQLALCHEMY_DISPLAY_TEXT ?? ''} - - {t(' for more information on how to structure your URI.')} + {' '} + {t('for more information on how to structure your URI.')}
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 27e841d02a42e..66d3138a81a33 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(, { 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(, { 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(, { 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 4547f8bc0d4b0..5cecee7880066 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,15 +98,27 @@ type DBReducerActionType = type: | ActionType.textChange | ActionType.inputChange - | ActionType.editorChange; + | ActionType.editorChange + | ActionType.parametersChange; payload: DBReducerPayloadType; } | { type: ActionType.fetched; payload: Partial; } + | { + 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, @@ -125,6 +162,15 @@ function dbReducer( [action.payload.name]: action.payload.value, }; 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, }; @@ -135,6 +181,7 @@ function dbReducer( } const DEFAULT_TAB_KEY = '1'; +const FALSY_FORM_VALUES = [undefined, null, '']; const DatabaseModal: FunctionComponent = ({ addDangerToast, @@ -148,11 +195,13 @@ const DatabaseModal: FunctionComponent = ({ Reducer | null, DBReducerActionType> >(dbReducer, null); const [tabKey, setTabKey] = useState(DEFAULT_TAB_KEY); + const [availableDbs, getAvailableDbs] = useAvailableDatabases(); + const [hasConnectedDb, setHasConnectedDb] = useState(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 = ({ 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 = ({ 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 = ({ }, [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 = ({ 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 ? ( - [ + antDTabsStyles, + antDModalStyles(theme), + antDModalNoPaddingStyles, + formHelperStyles(theme), + ]} name="database" - className="database-modal" disablePrimaryButton={disableSave} height="600px" onHandledPrimaryAction={onSave} @@ -302,11 +377,12 @@ const DatabaseModal: FunctionComponent = ({ )} - +
{t('Basic')}} key="1"> {useSqlAlchemyForm ? ( @@ -325,16 +401,17 @@ const DatabaseModal: FunctionComponent = ({ /> ) : (
-

TODO: db form

+

TODO: form

)} 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{' '} = ({ />
-
+ ) : ( - [ + 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={

{t('Connect a database')}

} > -
-

TODO: db form

-
-
+ {hasConnectedDb ? ( + + 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) + } + /> + ) : ( + <> + + 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, + }) + } + /> + + + )} + ); }; 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 38f5de7045cbc..8c33756962b0e 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 d0e6f5114d43f..2c386b5796a60 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 9ec1104341cc3..427549997cafc 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( .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(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 e43997d9bc7b2..347c9399de375 100644 --- a/superset/databases/schemas.py +++ b/superset/databases/schemas.py @@ -39,6 +39,7 @@ } 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. "