feat: db modal split (#14470)

* refactor: split db modal file (#14436)

* split db modal file

* fix tests

* fix extra options

* update database id methodology
This commit is contained in:
Elizabeth Thompson 2021-05-05 16:03:19 -07:00 committed by GitHub
parent 12baba03cc
commit 9b42eec064
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 861 additions and 694 deletions

View File

@ -29,6 +29,16 @@ describe('Add database', () => {
// open modal
cy.get('[data-test="btn-create-database"]').click();
// values should be blank
cy.get('[data-test="database-modal"] input[name="database_name"]').should(
'have.value',
'',
);
cy.get('[data-test="database-modal"] input[name="sqlalchemy_uri"]').should(
'have.value',
'',
);
// type values
cy.get('[data-test="database-modal"] input[name="database_name"]')
.focus()
@ -54,6 +64,14 @@ describe('Add database', () => {
// open modal
cy.get('[data-test="database-edit"]:last').click();
// it should show saved values
cy.get('[data-test="database-modal"]:last input[name="sqlalchemy_uri"]')
.invoke('val')
.should('not.be.empty');
cy.get('[data-test="database-modal"] input[name="database_name"]')
.invoke('val')
.should('not.be.empty');
cy.get('[data-test="database-modal"]:last input[name="sqlalchemy_uri"]')
.focus()
.dblclick()

View File

@ -53,5 +53,5 @@ export default {
explore,
sqlLab,
localStorageUsageInKilobytes,
common,
common: () => common,
};

View File

@ -414,7 +414,7 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
<>
<SubMenu {...menuData} />
<DatabaseModal
database={currentDatabase}
databaseId={currentDatabase?.id}
show={databaseModalOpen}
onHide={() => setDatabaseModalOpen(false)}
onDatabaseAdd={() => {

View File

@ -1,662 +0,0 @@
/**
* 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<DatabaseModalProps> = ({
addDangerToast,
addSuccessToast,
onDatabaseAdd,
onHide,
show,
database = null,
}) => {
const [disableSave, setDisableSave] = useState<boolean>(true);
const [db, setDB] = useState<DatabaseObject | null>(null);
const [isHidden, setIsHidden] = useState<boolean>(true);
const [tabKey, setTabKey] = useState<string>(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<DatabaseObject>(
'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<HTMLInputElement>) => {
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<HTMLTextAreaElement>) => {
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 (
<StyledModal
name="database"
className="database-modal"
disablePrimaryButton={disableSave}
height="600px"
onHandledPrimaryAction={onSave}
onHide={hide}
primaryButtonName={isEditMode ? t('Save') : t('Add')}
width="500px"
show={show}
title={<h4>{isEditMode ? t('Edit database') : t('Add database')}</h4>}
>
<Tabs
defaultActiveKey={DEFAULT_TAB_KEY}
activeKey={tabKey}
onTabClick={tabChange}
>
<StyledRequiredTab tab={<span>{t('Basic')}</span>} key="1">
<StyledInputContainer>
<div className="control-label">
{t('Display Name')}
<span className="required">*</span>
</div>
<div className="input-container">
<input
type="text"
name="database_name"
value={db?.database_name || ''}
placeholder={t('Name your dataset')}
onChange={onInputChange}
/>
</div>
<div className="helper">
{t('Pick a name to help you identify this database.')}
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label">
{t('SQLAlchemy URI')}
<span className="required">*</span>
</div>
<div className="input-container">
<input
type="text"
name="sqlalchemy_uri"
value={db?.sqlalchemy_uri || ''}
autoComplete="off"
placeholder={t(
'dialect+driver://username:password@host:port/database',
)}
onChange={onInputChange}
/>
</div>
<div className="helper">
{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.')}
</div>
</StyledInputContainer>
<Button
onClick={testConnection}
cta
buttonStyle="link"
style={{
width: '100%',
border: `1px solid ${supersetTheme.colors.primary.base}`,
}}
>
{t('Test connection')}
</Button>
</StyledRequiredTab>
<Tabs.TabPane tab={<span>{t('Advanced')}</span>} key="2">
<Collapse expandIconPosition="right" accordion>
<Collapse.Panel
header={
<div>
<h4>SQL Lab</h4>
<p className="helper">
Configure how this database will function in SQL Lab.
</p>
</div>
}
key="1"
>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="expose_in_sqllab"
indeterminate={false}
checked={!!db?.expose_in_sqllab}
onChange={onInputChange}
labelText={t('Expose in SQL Lab')}
/>
<InfoTooltip
tooltip={t('Allow this database to be queried in SQL Lab')}
/>
</div>
<StyledExpandableForm
className={cx('expandable', {
open: expandableModalIsOpen,
'ctas-open': createAsOpen,
})}
>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="allow_ctas"
indeterminate={false}
checked={!!db?.allow_ctas}
onChange={onInputChange}
labelText={t('Allow CREATE TABLE AS')}
/>
<InfoTooltip
tooltip={t(
'Allow creation of new tables based on queries',
)}
/>
</div>
</StyledInputContainer>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="allow_cvas"
indeterminate={false}
checked={!!db?.allow_cvas}
onChange={onInputChange}
labelText={t('Allow CREATE VIEW AS')}
/>
<InfoTooltip
tooltip={t(
'Allow creation of new views based on queries',
)}
/>
</div>
<StyledInputContainer
className={cx('expandable', { open: createAsOpen })}
>
<div className="control-label">
{t('CTAS & CVAS SCHEMA')}
</div>
<div className="input-container">
<input
type="text"
name="force_ctas_schema"
value={db?.force_ctas_schema || ''}
placeholder={t('Search or select schema')}
onChange={onInputChange}
/>
</div>
<div className="helper">
{t(
'When allowing CREATE TABLE AS option in SQL Lab, this option ' +
'forces the table to be created in this schema.',
)}
</div>
</StyledInputContainer>
</StyledInputContainer>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="allow_dml"
indeterminate={false}
checked={!!db?.allow_dml}
onChange={onInputChange}
labelText={t('Allow DML')}
/>
<InfoTooltip
tooltip={t(
'Allow manipulation of the database using non-SELECT statements such as UPDATE, DELETE, CREATE, etc.',
)}
/>
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="input-container">
<IndeterminateCheckbox
id="allow_multi_schema_metadata_fetch"
indeterminate={false}
checked={!!db?.allow_multi_schema_metadata_fetch}
onChange={onInputChange}
labelText={t('Allow multi schema metadata fetch')}
/>
<InfoTooltip
tooltip={t(
'Allow SQL Lab to fetch a list of all tables and all views across all database ' +
'schemas. For large data warehouse with thousands of tables, this can be ' +
'expensive and put strain on the system.',
)}
/>
</div>
</StyledInputContainer>
</StyledExpandableForm>
</StyledInputContainer>
</Collapse.Panel>
<Collapse.Panel
header={
<div>
<h4>Performance</h4>
<p className="helper">
Adjust settings that will impact the performance of this
database.
</p>
</div>
}
key="2"
>
<StyledInputContainer className="mb-8">
<div className="control-label">{t('Chart cache timeout')}</div>
<div className="input-container">
<input
type="number"
name="cache_timeout"
value={db?.cache_timeout || ''}
placeholder={t('Chart cache timeout')}
onChange={onInputChange}
/>
</div>
<div className="helper">
{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.',
)}
</div>
</StyledInputContainer>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="allow_run_async"
indeterminate={false}
checked={!!db?.allow_run_async}
onChange={onInputChange}
labelText={t('Asynchronous query execution')}
/>
<InfoTooltip
tooltip={t(
'Operate the database in asynchronous mode, meaning that the queries ' +
'are executed on remote workers as opposed to on the web server itself. ' +
'This assumes that you have a Celery worker setup as well as a results ' +
'backend. Refer to the installation docs for more information.',
)}
/>
</div>
</StyledInputContainer>
</Collapse.Panel>
<Collapse.Panel
header={
<div>
<h4>Security</h4>
<p className="helper">
Add connection information for other systems.
</p>
</div>
}
key="3"
>
<StyledInputContainer>
<div className="control-label">{t('Secure extra')}</div>
<div className="input-container">
<StyledJsonEditor
name="encrypted_extra"
value={db?.encrypted_extra || ''}
placeholder={t('Secure extra')}
onChange={(json: string) =>
onEditorChange(json, 'encrypted_extra')
}
width="100%"
height="160px"
/>
</div>
<div className="helper">
<div>
{t(
'JSON string containing additional connection configuration.',
)}
</div>
<div>
{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.',
)}
</div>
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label">{t('Root certificate')}</div>
<div className="input-container">
<textarea
name="server_cert"
value={db?.server_cert || ''}
placeholder={t('Root certificate')}
onChange={onTextChange}
/>
</div>
<div className="helper">
{t(
'Optional CA_BUNDLE contents to validate HTTPS requests. Only available on ' +
'certain database engines.',
)}
</div>
</StyledInputContainer>
</Collapse.Panel>
<Collapse.Panel
header={
<div>
<h4>Other</h4>
<p className="helper">Additional settings.</p>
</div>
}
key="4"
>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="impersonate_user"
indeterminate={false}
checked={!!db?.impersonate_user}
onChange={onInputChange}
labelText={t('Impersonate Logged In User (Presto & Hive)')}
/>
<InfoTooltip
tooltip={t(
'If Presto, all the queries in SQL Lab are going to be executed as the ' +
'currently logged on user who must have permission to run them. If Hive ' +
'and hive.server2.enable.doAs is enabled, will run the queries as ' +
'service account, but impersonate the currently logged on user via ' +
'hive.server2.proxy.user property.',
)}
/>
</div>
</StyledInputContainer>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="allow_csv_upload"
indeterminate={false}
checked={!!db?.allow_csv_upload}
onChange={onInputChange}
labelText={t('Allow data upload')}
/>
<InfoTooltip
tooltip={t(
'If selected, please set the schemas allowed for data upload in Extra.',
)}
/>
</div>
</StyledInputContainer>
<StyledInputContainer className="extra-container">
<div className="control-label">{t('Extra')}</div>
<div className="input-container">
<StyledJsonEditor
name="extra"
value={db?.extra ?? defaultExtra}
placeholder={t('Secure extra')}
onChange={(json: string) => onEditorChange(json, 'extra')}
width="100%"
height="160px"
/>
</div>
<div className="helper">
<div>
{t('JSON string containing extra configuration elements.')}
</div>
<div>
{t(
'1. The engine_params object gets unpacked into the sqlalchemy.create_engine ' +
'call, while the metadata_params gets unpacked into the sqlalchemy.MetaData ' +
'call.',
)}
</div>
<div>
{t(
'2. The metadata_cache_timeout is a cache timeout setting in seconds for ' +
'metadata fetch of this database. Specify it as "metadata_cache_timeout": ' +
'{"schema_cache_timeout": 600, "table_cache_timeout": 600}. If unset, cache ' +
'will not be enabled for the functionality. A timeout of 0 indicates that ' +
'the cache never expires.',
)}
</div>
<div>
{t(
'3. The schemas_allowed_for_csv_upload is a comma separated list of schemas ' +
'that CSVs are allowed to upload to. Specify it as ' +
'"schemas_allowed_for_csv_upload": ["public", "csv_upload"]. If database ' +
'flavor does not support schema or any schema is allowed to be accessed, ' +
'just leave the list empty.',
)}
</div>
<div>
{t(
"4. The version field is a string specifying this db's version. This " +
'should be used with Presto DBs so that the syntax is correct.',
)}
</div>
<div>
{t(
'5. The allows_virtual_table_explore field is a boolean specifying whether ' +
'or not the Explore button in SQL Lab results is shown.',
)}
</div>
</div>
</StyledInputContainer>
</Collapse.Panel>
</Collapse>
</Tabs.TabPane>
</Tabs>
</StyledModal>
);
};
export default withToasts(DatabaseModal);

View File

@ -0,0 +1,380 @@
/**
* 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, { ChangeEvent, EventHandler } from 'react';
import cx from 'classnames';
import { t } from '@superset-ui/core';
import InfoTooltip from 'src/components/InfoTooltip';
import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
import Collapse from 'src/components/Collapse';
import {
StyledInputContainer,
StyledJsonEditor,
StyledExpandableForm,
} from 'src/views/CRUD/data/database/DatabaseModal/styles';
import { DatabaseObject } from '../types';
const defaultExtra =
'{\n "metadata_params": {},\n "engine_params": {},' +
'\n "metadata_cache_timeout": {},\n "schemas_allowed_for_csv_upload": [] \n}';
const ExtraOptions = ({
db,
onInputChange,
onTextChange,
onEditorChange,
}: {
db: DatabaseObject | null;
onInputChange: EventHandler<ChangeEvent<HTMLInputElement>>;
onTextChange: EventHandler<ChangeEvent<HTMLTextAreaElement>>;
onEditorChange: Function;
}) => {
const expandableModalIsOpen = !!db?.expose_in_sqllab;
const createAsOpen = !!(db?.allow_ctas || db?.allow_cvas);
return (
<Collapse expandIconPosition="right" accordion>
<Collapse.Panel
header={
<div>
<h4>SQL Lab</h4>
<p className="helper">
Configure how this database will function in SQL Lab.
</p>
</div>
}
key="1"
>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="expose_in_sqllab"
indeterminate={false}
checked={!!db?.expose_in_sqllab}
onChange={onInputChange}
labelText={t('Expose in SQL Lab')}
/>
<InfoTooltip
tooltip={t('Allow this database to be queried in SQL Lab')}
/>
</div>
<StyledExpandableForm
className={cx('expandable', {
open: expandableModalIsOpen,
'ctas-open': createAsOpen,
})}
>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="allow_ctas"
indeterminate={false}
checked={!!db?.allow_ctas}
onChange={onInputChange}
labelText={t('Allow CREATE TABLE AS')}
/>
<InfoTooltip
tooltip={t('Allow creation of new tables based on queries')}
/>
</div>
</StyledInputContainer>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="allow_cvas"
indeterminate={false}
checked={!!db?.allow_cvas}
onChange={onInputChange}
labelText={t('Allow CREATE VIEW AS')}
/>
<InfoTooltip
tooltip={t('Allow creation of new views based on queries')}
/>
</div>
<StyledInputContainer
className={cx('expandable', { open: createAsOpen })}
>
<div className="control-label">{t('CTAS & CVAS SCHEMA')}</div>
<div className="input-container">
<input
type="text"
name="force_ctas_schema"
value={db?.force_ctas_schema || ''}
placeholder={t('Search or select schema')}
onChange={onInputChange}
/>
</div>
<div className="helper">
{t(
'When allowing CREATE TABLE AS option in SQL Lab, this option ' +
'forces the table to be created in this schema.',
)}
</div>
</StyledInputContainer>
</StyledInputContainer>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="allow_dml"
indeterminate={false}
checked={!!db?.allow_dml}
onChange={onInputChange}
labelText={t('Allow DML')}
/>
<InfoTooltip
tooltip={t(
'Allow manipulation of the database using non-SELECT statements such as UPDATE, DELETE, CREATE, etc.',
)}
/>
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="input-container">
<IndeterminateCheckbox
id="allow_multi_schema_metadata_fetch"
indeterminate={false}
checked={!!db?.allow_multi_schema_metadata_fetch}
onChange={onInputChange}
labelText={t('Allow multi schema metadata fetch')}
/>
<InfoTooltip
tooltip={t(
'Allow SQL Lab to fetch a list of all tables and all views across all database ' +
'schemas. For large data warehouse with thousands of tables, this can be ' +
'expensive and put strain on the system.',
)}
/>
</div>
</StyledInputContainer>
</StyledExpandableForm>
</StyledInputContainer>
</Collapse.Panel>
<Collapse.Panel
header={
<div>
<h4>Performance</h4>
<p className="helper">
Adjust settings that will impact the performance of this database.
</p>
</div>
}
key="2"
>
<StyledInputContainer className="mb-8">
<div className="control-label">{t('Chart cache timeout')}</div>
<div className="input-container">
<input
type="number"
name="cache_timeout"
value={db?.cache_timeout || ''}
placeholder={t('Chart cache timeout')}
onChange={onInputChange}
/>
</div>
<div className="helper">
{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.',
)}
</div>
</StyledInputContainer>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="allow_run_async"
indeterminate={false}
checked={!!db?.allow_run_async}
onChange={onInputChange}
labelText={t('Asynchronous query execution')}
/>
<InfoTooltip
tooltip={t(
'Operate the database in asynchronous mode, meaning that the queries ' +
'are executed on remote workers as opposed to on the web server itself. ' +
'This assumes that you have a Celery worker setup as well as a results ' +
'backend. Refer to the installation docs for more information.',
)}
/>
</div>
</StyledInputContainer>
</Collapse.Panel>
<Collapse.Panel
header={
<div>
<h4>Security</h4>
<p className="helper">
Add connection information for other systems.
</p>
</div>
}
key="3"
>
<StyledInputContainer>
<div className="control-label">{t('Secure extra')}</div>
<div className="input-container">
<StyledJsonEditor
name="encrypted_extra"
value={db?.encrypted_extra || ''}
placeholder={t('Secure extra')}
onChange={(json: string) =>
onEditorChange({ json, name: 'encrypted_extra' })
}
width="100%"
height="160px"
/>
</div>
<div className="helper">
<div>
{t('JSON string containing additional connection configuration.')}
</div>
<div>
{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.',
)}
</div>
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label">{t('Root certificate')}</div>
<div className="input-container">
<textarea
name="server_cert"
value={db?.server_cert || ''}
placeholder={t('Root certificate')}
onChange={onTextChange}
/>
</div>
<div className="helper">
{t(
'Optional CA_BUNDLE contents to validate HTTPS requests. Only available on ' +
'certain database engines.',
)}
</div>
</StyledInputContainer>
</Collapse.Panel>
<Collapse.Panel
header={
<div>
<h4>Other</h4>
<p className="helper">Additional settings.</p>
</div>
}
key="4"
>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="impersonate_user"
indeterminate={false}
checked={!!db?.impersonate_user}
onChange={onInputChange}
labelText={t('Impersonate Logged In User (Presto & Hive)')}
/>
<InfoTooltip
tooltip={t(
'If Presto, all the queries in SQL Lab are going to be executed as the ' +
'currently logged on user who must have permission to run them. If Hive ' +
'and hive.server2.enable.doAs is enabled, will run the queries as ' +
'service account, but impersonate the currently logged on user via ' +
'hive.server2.proxy.user property.',
)}
/>
</div>
</StyledInputContainer>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="allow_csv_upload"
indeterminate={false}
checked={!!db?.allow_csv_upload}
onChange={onInputChange}
labelText={t('Allow data upload')}
/>
<InfoTooltip
tooltip={t(
'If selected, please set the schemas allowed for data upload in Extra.',
)}
/>
</div>
</StyledInputContainer>
<StyledInputContainer className="extra-container">
<div className="control-label">{t('Extra')}</div>
<div className="input-container">
<StyledJsonEditor
name="extra"
value={db?.extra ?? defaultExtra}
placeholder={t('Secure extra')}
onChange={(json: string) =>
onEditorChange({ json, name: 'extra' })
}
width="100%"
height="160px"
/>
</div>
<div className="helper">
<div>
{t('JSON string containing extra configuration elements.')}
</div>
<div>
{t(
'1. The engine_params object gets unpacked into the sqlalchemy.create_engine ' +
'call, while the metadata_params gets unpacked into the sqlalchemy.MetaData ' +
'call.',
)}
</div>
<div>
{t(
'2. The metadata_cache_timeout is a cache timeout setting in seconds for ' +
'metadata fetch of this database. Specify it as "metadata_cache_timeout": ' +
'{"schema_cache_timeout": 600, "table_cache_timeout": 600}. If unset, cache ' +
'will not be enabled for the functionality. A timeout of 0 indicates that ' +
'the cache never expires.',
)}
</div>
<div>
{t(
'3. The schemas_allowed_for_csv_upload is a comma separated list of schemas ' +
'that CSVs are allowed to upload to. Specify it as ' +
'"schemas_allowed_for_csv_upload": ["public", "csv_upload"]. If database ' +
'flavor does not support schema or any schema is allowed to be accessed, ' +
'just leave the list empty.',
)}
</div>
<div>
{t(
"4. The version field is a string specifying this db's version. This " +
'should be used with Presto DBs so that the syntax is correct.',
)}
</div>
<div>
{t(
'5. The allows_virtual_table_explore field is a boolean specifying whether ' +
'or not the Explore button in SQL Lab results is shown.',
)}
</div>
</div>
</StyledInputContainer>
</Collapse.Panel>
</Collapse>
);
};
export default ExtraOptions;

View File

@ -0,0 +1,99 @@
/**
* 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, { EventHandler, ChangeEvent, MouseEvent } from 'react';
import { t, supersetTheme } from '@superset-ui/core';
import Button from 'src/components/Button';
import { StyledInputContainer } from './styles';
import { DatabaseObject } from '../types';
const SqlAlchemyTab = ({
db,
onInputChange,
testConnection,
conf,
}: {
db: DatabaseObject | null;
onInputChange: EventHandler<ChangeEvent<HTMLInputElement>>;
testConnection: EventHandler<MouseEvent<HTMLElement>>;
conf: { SQLALCHEMY_DOCS_URL: string; SQLALCHEMY_DISPLAY_TEXT: string };
}) => (
<>
<StyledInputContainer>
<div className="control-label">
{t('Display Name')}
<span className="required">*</span>
</div>
<div className="input-container">
<input
type="text"
name="database_name"
value={db?.database_name || ''}
placeholder={t('Name your dataset')}
onChange={onInputChange}
/>
</div>
<div className="helper">
{t('Pick a name to help you identify this database.')}
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label">
{t('SQLAlchemy URI')}
<span className="required">*</span>
</div>
<div className="input-container">
<input
type="text"
name="sqlalchemy_uri"
value={db?.sqlalchemy_uri || ''}
autoComplete="off"
placeholder={t(
'dialect+driver://username:password@host:port/database',
)}
onChange={onInputChange}
/>
</div>
<div className="helper">
{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.')}
</div>
</StyledInputContainer>
<Button
onClick={testConnection}
cta
buttonStyle="link"
style={{
width: '100%',
border: `1px solid ${supersetTheme.colors.primary.base}`,
}}
>
{t('Test connection')}
</Button>
</>
);
export default SqlAlchemyTab;

View File

@ -20,16 +20,18 @@ import React from 'react';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import * as redux from 'react-redux';
import { styledMount as mount } from 'spec/helpers/theming';
import { render, screen } from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
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 fetchMock from 'fetch-mock';
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 Modal from 'src/components/Modal';
import Tabs from 'src/components/Tabs';
import DatabaseModal from './index';
// store needed for withToasts(DatabaseModal)
const mockStore = configureStore([thunk]);
@ -39,16 +41,19 @@ const mockedProps = {
};
const dbProps = {
show: true,
database: {
id: 10,
database_name: 'test',
sqlalchemy_uri: 'sqllite:///user:pw/test',
expose_in_sqllab: true,
},
databaseId: 10,
};
const DATABASE_ENDPOINT = 'glob:*/api/v1/database/*';
fetchMock.get(DATABASE_ENDPOINT, {});
fetchMock.get(DATABASE_ENDPOINT, {
result: {
id: 1,
database_name: 'my database',
expose_in_sqllab: false,
allow_ctas: false,
allow_cvas: false,
},
});
describe('DatabaseModal', () => {
describe('enzyme', () => {
@ -74,8 +79,8 @@ 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 "Connect a database" header when no database is included', () => {
expect(wrapper.find('h4').text()).toEqual('Connect a database');
});
it('renders "Edit database" header when database prop is included', () => {
const editWrapper = mount(<DatabaseModal store={store} {...dbProps} />);
@ -99,17 +104,7 @@ describe('DatabaseModal', () => {
describe('RTL', () => {
describe('initial load', () => {
it('hides the forms from the db when not selected', () => {
render(
<DatabaseModal
show
database={{
expose_in_sqllab: false,
allow_ctas: false,
allow_cvas: false,
}}
/>,
{ useRedux: true },
);
render(<DatabaseModal show databaseId={1} />, { useRedux: true });
// Select Advanced tab
const advancedTab = screen.getByRole('tab', {
name: /advanced/i,
@ -151,10 +146,7 @@ describe('DatabaseModal', () => {
name: /expose in sql lab/i,
});
// While 'Expose in SQL Lab' is checked, all settings should display
expect(exposeInSqlLab).not.toBeChecked();
// When clicked, "Expose in SQL Lab" becomes unchecked
userEvent.click(exposeInSqlLab);
// While checked make sure all checkboxes are showing

View File

@ -0,0 +1,340 @@
/**
* 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 { t } from '@superset-ui/core';
import React, {
FunctionComponent,
useEffect,
useState,
useReducer,
Reducer,
} from 'react';
import Tabs from 'src/components/Tabs';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import {
testDatabaseConnection,
useSingleViewResource,
} from 'src/views/CRUD/hooks';
import { useCommonConf } from 'src/views/CRUD/data/database/state';
import { DatabaseObject } from 'src/views/CRUD/data/database/types';
import ExtraOptions from './ExtraOptions';
import SqlAlchemyForm from './SqlAlchemyForm';
import { StyledBasicTab, StyledModal } 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;
databaseId: number | undefined; // If included, will go into edit mode
}
enum ActionType {
textChange,
inputChange,
editorChange,
fetched,
reset,
}
interface DBReducerPayloadType {
target?: string;
name: string;
json?: {};
type?: string;
checked?: boolean;
value?: string;
}
type DBReducerActionType =
| {
type:
| ActionType.textChange
| ActionType.inputChange
| ActionType.editorChange;
payload: DBReducerPayloadType;
}
| {
type: ActionType.fetched;
payload: Partial<DatabaseObject>;
}
| {
type: ActionType.reset;
};
function dbReducer(
state: Partial<DatabaseObject> | null,
action: DBReducerActionType,
): Partial<DatabaseObject> | null {
const trimmedState = {
...(state || {}),
database_name: state?.database_name?.trim() || '',
sqlalchemy_uri: state?.sqlalchemy_uri || '',
};
switch (action.type) {
case ActionType.inputChange:
if (action.payload.type === 'checkbox') {
return {
...trimmedState,
[action.payload.name]: action.payload.checked,
};
}
return {
...trimmedState,
[action.payload.name]: action.payload.value,
};
case ActionType.editorChange:
return {
...trimmedState,
[action.payload.name]: action.payload.json,
};
case ActionType.textChange:
return {
...trimmedState,
[action.payload.name]: action.payload.value,
};
case ActionType.fetched:
return {
...action.payload,
};
case ActionType.reset:
default:
return {};
}
}
const DEFAULT_TAB_KEY = '1';
const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
addDangerToast,
addSuccessToast,
onDatabaseAdd,
onHide,
show,
databaseId,
}) => {
const [db, setDB] = useReducer<
Reducer<Partial<DatabaseObject> | null, DBReducerActionType>
>(dbReducer, null);
const [tabKey, setTabKey] = useState<string>(DEFAULT_TAB_KEY);
const conf = useCommonConf();
const isEditMode = !!databaseId;
const useSqlAlchemyForm = true; // TODO: set up logic
const hasConnectedDb = false; // TODO: set up logic
// Database fetch logic
const {
state: { loading: dbLoading, resource: dbFetched },
fetchResource,
createResource,
updateResource,
} = useSingleViewResource<DatabaseObject>(
'database',
t('database'),
addDangerToast,
);
// Test Connection logic
const testConnection = () => {
if (!db?.sqlalchemy_uri) {
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);
};
const onClose = () => {
setDB({ type: ActionType.reset });
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();
}
},
);
} else if (db) {
// Create
db.database_name = db?.database_name?.trim();
createResource(db as DatabaseObject).then(dbId => {
if (dbId) {
if (onDatabaseAdd) {
onDatabaseAdd();
}
onClose();
}
});
}
};
const disableSave = !(db?.database_name?.trim() && db?.sqlalchemy_uri);
const onChange = (type: any, payload: any) => {
setDB({ type, payload } as DBReducerActionType);
};
// Initialize
const fetchDB = () => {
if (isEditMode && databaseId) {
if (!dbLoading) {
fetchResource(databaseId).catch(e =>
addDangerToast(
t(
'Sorry there was an error fetching database information: %s',
e.message,
),
),
);
}
}
};
useEffect(() => {
if (show) {
setTabKey(DEFAULT_TAB_KEY);
}
if (databaseId && show) {
fetchDB();
}
}, [show, databaseId]);
useEffect(() => {
// TODO: can we include these values in the original fetch?
if (dbFetched) {
setDB({
type: ActionType.fetched,
payload: {
...dbFetched,
},
});
}
}, [dbFetched]);
const tabChange = (key: string) => {
setTabKey(key);
};
return isEditMode || useSqlAlchemyForm ? (
<StyledModal
name="database"
className="database-modal"
disablePrimaryButton={disableSave}
height="600px"
onHandledPrimaryAction={onSave}
onHide={onClose}
primaryButtonName={isEditMode ? t('Save') : t('Connect')}
width="500px"
show={show}
title={
<h4>{isEditMode ? t('Edit database') : t('Connect a database')}</h4>
}
>
<Tabs
defaultActiveKey={DEFAULT_TAB_KEY}
activeKey={tabKey}
onTabClick={tabChange}
>
<StyledBasicTab tab={<span>{t('Basic')}</span>} key="1">
{useSqlAlchemyForm ? (
<SqlAlchemyForm
db={db as DatabaseObject}
onInputChange={({ target }: { target: HTMLInputElement }) =>
onChange(ActionType.inputChange, {
type: target.type,
name: target.name,
checked: target.checked,
value: target.value,
})
}
conf={conf}
testConnection={testConnection}
/>
) : (
<div>
<p>TODO: db form</p>
</div>
)}
</StyledBasicTab>
<Tabs.TabPane tab={<span>{t('Advanced')}</span>} key="2">
<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)
}
/>
</Tabs.TabPane>
</Tabs>
</StyledModal>
) : (
<StyledModal
name="database"
className="database-modal"
disablePrimaryButton={disableSave}
height="600px"
onHandledPrimaryAction={onSave}
onHide={onClose}
primaryButtonName={hasConnectedDb ? t('Connect') : t('Finish')}
width="500px"
show={show}
title={<h4>{t('Connect a database')}</h4>}
>
<div>
<p>TODO: db form</p>
</div>
</StyledModal>
);
};
export default withToasts(DatabaseModal);

View File

@ -196,7 +196,7 @@ export const StyledExpandableForm = styled.div`
}
`;
export const StyledRequiredTab = styled(Tabs.TabPane)`
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;