diff --git a/superset-frontend/spec/helpers/reducerIndex.ts b/superset-frontend/spec/helpers/reducerIndex.ts index e84b7f6f5119..f1cbfd47ffa4 100644 --- a/superset-frontend/spec/helpers/reducerIndex.ts +++ b/superset-frontend/spec/helpers/reducerIndex.ts @@ -53,5 +53,5 @@ export default { explore, sqlLab, localStorageUsageInKilobytes, - common: () => common, + common, }; diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal.test.jsx similarity index 96% rename from superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx rename to superset-frontend/src/views/CRUD/data/database/DatabaseModal.test.jsx index d6088268d359..18b8c7bf8d00 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal.test.jsx @@ -20,18 +20,16 @@ import React from 'react'; import thunk from 'redux-thunk'; import configureStore from 'redux-mock-store'; import * as redux from 'react-redux'; -import fetchMock from 'fetch-mock'; -import userEvent from '@testing-library/user-event'; -import { Provider } from 'react-redux'; - -import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; -import { initialState } from 'spec/javascripts/sqllab/fixtures'; import { styledMount as mount } from 'spec/helpers/theming'; import { render, screen } from 'spec/helpers/testing-library'; - +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import DatabaseModal from 'src/views/CRUD/data/database/DatabaseModal'; import Modal from 'src/components/Modal'; import Tabs from 'src/components/Tabs'; -import DatabaseModal from './index'; +import fetchMock from 'fetch-mock'; +import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; +import { initialState } from 'spec/javascripts/sqllab/fixtures'; // store needed for withToasts(DatabaseModal) const mockStore = configureStore([thunk]); @@ -76,8 +74,8 @@ describe('DatabaseModal', () => { it('renders a Modal', () => { expect(wrapper.find(Modal)).toExist(); }); - it('renders "Connect a database" header when no database is included', () => { - expect(wrapper.find('h4').text()).toEqual('Connect a database'); + 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', () => { const editWrapper = mount(); @@ -154,7 +152,10 @@ describe('DatabaseModal', () => { }); // While 'Expose in SQL Lab' is checked, all settings should display - expect(exposeInSqlLab).toBeChecked(); + expect(exposeInSqlLab).not.toBeChecked(); + + // When clicked, "Expose in SQL Lab" becomes unchecked + userEvent.click(exposeInSqlLab); // While checked make sure all checkboxes are showing expect(exposeInSqlLab).toBeChecked(); diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx new file mode 100644 index 000000000000..4e3f6e19b5cf --- /dev/null +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal.tsx @@ -0,0 +1,662 @@ +/** + * 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, { FunctionComponent, useState, useEffect } from 'react'; +import cx from 'classnames'; +import InfoTooltip from 'src/components/InfoTooltip'; +import { t, supersetTheme } from '@superset-ui/core'; +import { + useSingleViewResource, + testDatabaseConnection, +} from 'src/views/CRUD/hooks'; +import withToasts from 'src/messageToasts/enhancers/withToasts'; +import Tabs from 'src/components/Tabs'; +import Button from 'src/components/Button'; +import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox'; +import Collapse from 'src/components/Collapse'; +import { DatabaseObject } from './types'; +import { useCommonConf } from './state'; +import { + StyledModal, + StyledInputContainer, + StyledJsonEditor, + StyledExpandableForm, + StyledRequiredTab, +} from './styles'; + +interface DatabaseModalProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; + onDatabaseAdd?: (database?: DatabaseObject) => void; // TODO: should we add a separate function for edit? + onHide: () => void; + show: boolean; + database?: DatabaseObject | null; // If included, will go into edit mode +} + +const DEFAULT_TAB_KEY = '1'; + +const DatabaseModal: FunctionComponent = ({ + addDangerToast, + addSuccessToast, + onDatabaseAdd, + onHide, + show, + database = null, +}) => { + const [disableSave, setDisableSave] = useState(true); + const [db, setDB] = useState(null); + const [isHidden, setIsHidden] = useState(true); + const [tabKey, setTabKey] = useState(DEFAULT_TAB_KEY); + const conf = useCommonConf(); + + const isEditMode = database !== null; + const defaultExtra = + '{\n "metadata_params": {},\n "engine_params": {},' + + '\n "metadata_cache_timeout": {},\n "schemas_allowed_for_csv_upload": [] \n}'; + + // 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?.sqlalchemy_uri || '', + database_name: db?.database_name?.trim() || undefined, + impersonate_user: db?.impersonate_user || undefined, + extra: db?.extra || undefined, + encrypted_extra: db?.encrypted_extra || undefined, + server_cert: db?.server_cert || undefined, + }; + + testDatabaseConnection(connection, addDangerToast, addSuccessToast); + }; + + // Functions + const hide = () => { + setIsHidden(true); + onHide(); + }; + + const onSave = () => { + if (isEditMode) { + // Edit + const update: DatabaseObject = { + database_name: db?.database_name.trim() || '', + sqlalchemy_uri: db?.sqlalchemy_uri || '', + ...db, + }; + + // Need to clean update object + if (update.id) { + delete update.id; + } + + if (db?.id) { + updateResource(db.id, update).then(result => { + if (result) { + if (onDatabaseAdd) { + onDatabaseAdd(); + } + hide(); + } + }); + } + } else if (db) { + // Create + db.database_name = db.database_name.trim(); + createResource(db).then(dbId => { + if (dbId) { + if (onDatabaseAdd) { + onDatabaseAdd(); + } + hide(); + } + }); + } + }; + + const onInputChange = (event: React.ChangeEvent) => { + const { target } = event; + const { checked, name, value, type } = target; + const data = { + database_name: db?.database_name || '', + sqlalchemy_uri: db?.sqlalchemy_uri || '', + ...db, + }; + + if (type === 'checkbox') { + data[name] = checked; + } else { + data[name] = value; + } + + setDB(data); + }; + + const onTextChange = (event: React.ChangeEvent) => { + const { target } = event; + const { name, value } = target; + const data = { + database_name: db?.database_name || '', + sqlalchemy_uri: db?.sqlalchemy_uri || '', + ...db, + }; + + data[name] = value; + setDB(data); + }; + + const onEditorChange = (json: string, name: string) => { + const data = { + database_name: db?.database_name || '', + sqlalchemy_uri: db?.sqlalchemy_uri || '', + ...db, + }; + + data[name] = json; + setDB(data); + }; + + const validate = () => { + if (db?.database_name?.trim() && db?.sqlalchemy_uri) { + setDisableSave(false); + } else { + setDisableSave(true); + } + }; + + // Initialize + if ( + isEditMode && + (!db || !db.id || database?.id !== db.id || (isHidden && show)) + ) { + if (database?.id && !dbLoading) { + const id = database.id || 0; + setTabKey(DEFAULT_TAB_KEY); + + fetchResource(id) + .then(() => { + setDB(dbFetched); + }) + .catch(e => + addDangerToast( + t( + 'Sorry there was an error fetching database information: %s', + e.message, + ), + ), + ); + } + } else if (!isEditMode && (!db || db.id || (isHidden && show))) { + setTabKey(DEFAULT_TAB_KEY); + setDB({ + database_name: '', + sqlalchemy_uri: '', + }); + } + + // Validation + useEffect(() => { + validate(); + }, [db?.database_name || null, db?.sqlalchemy_uri || null]); + + // Show/hide + if (isHidden && show) { + setIsHidden(false); + } + + const tabChange = (key: string) => { + setTabKey(key); + }; + + const expandableModalIsOpen = !!db?.expose_in_sqllab; + const createAsOpen = !!(db?.allow_ctas || db?.allow_cvas); + + return ( + {isEditMode ? t('Edit database') : t('Add database')}} + > + + {t('Basic')}} key="1"> + +
+ {t('Display Name')} + * +
+
+ +
+
+ {t('Pick a name to help you identify this database.')} +
+
+ +
+ {t('SQLAlchemy URI')} + * +
+
+ +
+
+ {t('Refer to the ')} + + {conf?.SQLALCHEMY_DISPLAY_TEXT ?? ''} + + {t(' for more information on how to structure your URI.')} +
+
+ +
+ {t('Advanced')}} key="2"> + + +

SQL Lab

+

+ Configure how this database will function in SQL Lab. +

+ + } + key="1" + > + +
+ + +
+ + +
+ + +
+
+ +
+ + +
+ +
+ {t('CTAS & CVAS SCHEMA')} +
+
+ +
+
+ {t( + 'When allowing CREATE TABLE AS option in SQL Lab, this option ' + + 'forces the table to be created in this schema.', + )} +
+
+
+ +
+ + +
+
+ +
+ + +
+
+
+
+
+ +

Performance

+

+ Adjust settings that will impact the performance of this + database. +

+ + } + key="2" + > + +
{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.', + )} +
+
+ +
+ + +
+
+
+ +

Security

+

+ Add connection information for other systems. +

+ + } + key="3" + > + +
{t('Secure extra')}
+
+ + onEditorChange(json, 'encrypted_extra') + } + width="100%" + height="160px" + /> +
+
+
+ {t( + 'JSON string containing additional connection configuration.', + )} +
+
+ {t( + 'This is used to provide connection information for systems like Hive, ' + + 'Presto, and BigQuery, which do not conform to the username:password syntax ' + + 'normally used by SQLAlchemy.', + )} +
+
+
+ +
{t('Root certificate')}
+
+