diff --git a/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseModal_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseModal_spec.jsx index 8b8e29b989..e4c16fd645 100644 --- a/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseModal_spec.jsx +++ b/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseModal_spec.jsx @@ -23,13 +23,32 @@ import { styledMount as mount } from 'spec/helpers/theming'; import DatabaseModal from 'src/views/CRUD/data/database/DatabaseModal'; import Modal from 'src/common/components/Modal'; +import Tabs from 'src/common/components/Tabs'; // store needed for withToasts(DatabaseModal) const mockStore = configureStore([thunk]); const store = mockStore({}); +const mockedProps = { + show: true, +}; + +const dbProps = { + show: true, + database: { + id: 10, + database_name: 'test', + sqlalchemy_uri: 'sqllite:///user:pw/test', + }, +}; + describe('DatabaseModal', () => { - const wrapper = mount(, { context: { store } }); + const wrapper = mount(, { + context: { store }, + }); + const editWrapper = mount(, { + context: { store }, + }); it('renders', () => { expect(wrapper.find(DatabaseModal)).toExist(); @@ -38,4 +57,26 @@ describe('DatabaseModal', () => { it('renders a Modal', () => { expect(wrapper.find(Modal)).toExist(); }); + + it('renders "Add Database" header when no database is included', () => { + expect(wrapper.find('h4').text()).toEqual('Add Database'); + }); + + it('renders "Edit Database" header when database prop is included', () => { + expect(editWrapper.find('h4').text()).toEqual('Edit Database'); + }); + + it('renders a Tabs menu', () => { + expect(wrapper.find(Tabs)).toExist(); + }); + + it('renders five TabPanes', () => { + expect(wrapper.find(Tabs.TabPane)).toExist(); + expect(wrapper.find(Tabs.TabPane)).toHaveLength(5); + }); + + it('renders input elements for Connection section', () => { + expect(wrapper.find('input[name="database_name"]')).toExist(); + expect(wrapper.find('input[name="sqlalchemy_uri"]')).toExist(); + }); }); diff --git a/superset-frontend/src/common/components/Modal.tsx b/superset-frontend/src/common/components/Modal.tsx index 5895c40587..9e97f6f2d8 100644 --- a/superset-frontend/src/common/components/Modal.tsx +++ b/superset-frontend/src/common/components/Modal.tsx @@ -107,13 +107,15 @@ export default function Modal({ } footer={[ - , , diff --git a/superset-frontend/src/common/components/Tabs.tsx b/superset-frontend/src/common/components/Tabs.tsx index a02401581f..932e444b1d 100644 --- a/superset-frontend/src/common/components/Tabs.tsx +++ b/superset-frontend/src/common/components/Tabs.tsx @@ -40,6 +40,7 @@ const StyledTabs = styled(AntdTabs)` font-size: ${({ theme }) => theme.typography.sizes.s}px; text-align: center; text-transform: uppercase; + user-select: none; .required { margin-left: ${({ theme }) => theme.gridUnit / 2}px; diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx index 7917cdca5a..be1e0acded 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx @@ -100,6 +100,9 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { () => { refreshData(); addSuccessToast(t('Deleted: %s', dbName)); + + // Close delete modal + setDatabaseCurrentlyDeleting(null); }, createErrorHandler(errMsg => addDangerToast(t('There was an issue deleting %s: %s', dbName, errMsg)), @@ -107,7 +110,14 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { ); } + function handleDatabaseEdit(database: DatabaseObject) { + // Set database and open modal + setCurrentDatabase(database); + setDatabaseModalOpen(true); + } + const canCreate = hasPerm('can_add'); + const canEdit = hasPerm('can_edit'); const canDelete = hasPerm('can_delete'); const menuData: SubMenuProps = { @@ -224,12 +234,29 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { }, { Cell: ({ row: { original } }: any) => { + const handleEdit = () => handleDatabaseEdit(original); const handleDelete = () => openDatabaseDeleteModal(original); - if (!canDelete) { + if (!canEdit && !canDelete) { return null; } return ( + {canEdit && ( + + + + + + )} {canDelete && ( setDatabaseModalOpen(false)} onDatabaseAdd={() => { - /* TODO: add database logic here */ + refreshData(); }} /> {databaseCurrentlyDeleting && ( diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx index de73614a07..3000ee8e2f 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx @@ -16,12 +16,16 @@ * specific language governing permissions and limitations * under the License. */ -import React, { FunctionComponent, useState } from 'react'; -import { styled, t } from '@superset-ui/core'; +import React, { FunctionComponent, useState, useEffect } from 'react'; +import { styled, t, SupersetClient } from '@superset-ui/core'; +import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; +import { useSingleViewResource } from 'src/views/CRUD/hooks'; import withToasts from 'src/messageToasts/enhancers/withToasts'; import Icon from 'src/components/Icon'; import Modal from 'src/common/components/Modal'; import Tabs from 'src/common/components/Tabs'; +import Button from 'src/components/Button'; +import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; import { DatabaseObject } from './types'; interface DatabaseModalProps { @@ -45,7 +49,7 @@ const StyledInputContainer = styled.div` display: block; padding: ${({ theme }) => theme.gridUnit}px 0; color: ${({ theme }) => theme.colors.grayscale.light1}; - font-size: ${({ theme }) => theme.typography.sizes.s}px; + font-size: ${({ theme }) => theme.typography.sizes.s - 1}px; text-align: left; .required { @@ -56,10 +60,41 @@ const StyledInputContainer = styled.div` .input-container { display: flex; + align-items: center; + + label { + display: flex; + margin-right: ${({ theme }) => theme.gridUnit * 2}px; + } + + i { + margin: 0 ${({ theme }) => theme.gridUnit}px; + } + + .btn-primary { + height: 36px; + font-size: ${({ theme }) => theme.typography.sizes.s - 1}px; + } } - input[type='text'] { + input, + textarea { flex: 1 1 auto; + } + + textarea { + height: 160px; + resize: none; + } + + input::placeholder, + textarea::placeholder { + color: ${({ theme }) => theme.colors.grayscale.light1}; + } + + textarea, + input[type='text'], + input[type='number'] { padding: ${({ theme }) => theme.gridUnit * 1.5}px ${({ theme }) => theme.gridUnit * 2}px; border-style: none; @@ -70,6 +105,10 @@ const StyledInputContainer = styled.div` flex: 0 1 auto; width: 40%; } + + &[name='sqlalchemy_uri'] { + margin-right: ${({ theme }) => theme.gridUnit * 3}px; + } } `; @@ -81,11 +120,55 @@ const DatabaseModal: FunctionComponent = ({ show, database = null, }) => { - // const [disableSave, setDisableSave] = useState(true); - const [disableSave] = useState(true); - const [db, setDB] = useState | null>(null); + const [disableSave, setDisableSave] = useState(true); + const [db, setDB] = useState(null); const [isHidden, setIsHidden] = useState(true); + const isEditMode = database !== null; + + // Database fetch logic + const { + state: { loading: dbLoading, resource: dbFetched }, + fetchResource, + createResource, + updateResource, + } = useSingleViewResource( + 'database', + t('database'), + addDangerToast, + ); + + // Test Connection logic + const testConnection = () => { + if (!db || !db.sqlalchemy_uri || !db.sqlalchemy_uri.length) { + addDangerToast(t('Please enter a SQLAlchemy URI to test')); + return; + } + + const connection = { + sqlalchemy_uri: db ? db.sqlalchemy_uri : '', + database_name: + db && db.database_name.length ? db.database_name : undefined, + impersonate_user: db ? db.impersonate_user || undefined : undefined, + extra: db && db.extra && db.extra.length ? db.extra : undefined, + encrypted_extra: db ? db.encrypted_extra || undefined : undefined, + server_cert: db ? db.server_cert || undefined : undefined, + }; + + SupersetClient.post({ + endpoint: 'api/v1/database/test_connection', + postPayload: JSON.stringify(connection), + }) + .then(() => { + addSuccessToast(t('Connection looks good!')); + }) + .catch(() => { + addDangerToast( + t('ERROR: Connection failed, please check your connection settings'), + ); + }); + }; + // Functions const hide = () => { setIsHidden(true); @@ -93,41 +176,107 @@ const DatabaseModal: FunctionComponent = ({ }; const onSave = () => { - if (onDatabaseAdd) { - onDatabaseAdd(); - } + if (isEditMode) { + // Edit + const update: DatabaseObject = { + database_name: db ? db.database_name : '', + sqlalchemy_uri: db ? db.sqlalchemy_uri : '', + ...db, + }; - hide(); + // Need to clean update object + if (update.id) { + delete update.id; + } + + if (db && db.id) { + updateResource(db.id, update).then(() => { + if (onDatabaseAdd) { + onDatabaseAdd(); + } + + hide(); + }); + } + } else if (db) { + // Create + createResource(db).then(() => { + if (onDatabaseAdd) { + onDatabaseAdd(); + } + + hide(); + }); + } }; const onInputChange = (event: React.ChangeEvent) => { const target = event.target; const data = { database_name: db ? db.database_name : '', - uri: db ? db.uri : '', + sqlalchemy_uri: db ? db.sqlalchemy_uri : '', ...db, }; - data[target.name] = target.value; + if (target.type === 'checkbox') { + data[target.name] = target.checked; + } else { + data[target.name] = + typeof target.value === 'string' ? target.value.trim() : target.value; + } setDB(data); }; - const isEditMode = database !== null; + const onTextChange = (event: React.ChangeEvent) => { + const target = event.target; + const data = { + database_name: db ? db.database_name : '', + sqlalchemy_uri: db ? db.sqlalchemy_uri : '', + ...db, + }; + + data[target.name] = target.value; + setDB(data); + }; + + const validate = () => { + if ( + db && + db.database_name.length && + db.sqlalchemy_uri && + db.sqlalchemy_uri.length + ) { + setDisableSave(false); + } else { + setDisableSave(true); + } + }; // Initialize if ( isEditMode && (!db || !db.id || (database && database.id !== db.id) || (isHidden && show)) ) { - setDB(database); + if (database && database.id !== null && !dbLoading) { + const id = database.id || 0; + + fetchResource(id).then(() => { + setDB(dbFetched); + }); + } } else if (!isEditMode && (!db || db.id || (isHidden && show))) { setDB({ database_name: '', - uri: '', + sqlalchemy_uri: '', }); } + // Validation + useEffect(() => { + validate(); + }, [db ? db.database_name : null, db ? db.sqlalchemy_uri : null]); + // Show/hide if (isHidden && show) { setIsHidden(false); @@ -167,7 +316,7 @@ const DatabaseModal: FunctionComponent = ({
= ({
+
{t('Refer to the ')} @@ -202,16 +354,288 @@ const DatabaseModal: FunctionComponent = ({ {t('Performance')}} key="2"> - Performance Form Data + +
{t('Chart Cache Timeout')}
+
+ +
+
+ {t( + 'Duration (in seconds) of the caching timeout for charts of this database.' + + ' A timeout of 0 indicates that the cache never expires.' + + ' Note this defaults to the global timeout if undefined.', + )} +
+
+ +
+ +
{t('Asynchronous Query Execution')}
+ +
+
{t('SQL Lab Settings')}} key="3"> - SQL Lab Settings Form Data + + +
+ +
{t('Expose in SQL Lab')}
+ +
+
+ +
+ +
{t('Allow CREATE TABLE AS')}
+ +
+
+ +
+ +
{t('Allow CREATE VIEW AS')}
+ +
+
+ +
+ +
{t('Allow DML')}
+ +
+
+ +
+ +
{t('Allow Multi Schema Metadata Fetch')}
+ +
+
+
+ +
{t('CTAS Schema')}
+
+ +
+
+ {t( + 'When allowing CREATE TABLE AS option in SQL Lab, this option ' + + 'forces the table to be created in this schema.', + )} +
+
{t('Security')}} key="4"> - Security Form Data + +
{t('Secure Extra')}
+
+