mirror of
https://github.com/apache/superset.git
synced 2026-04-17 15:15:20 +00:00
* fix error for query.update * converrt before making request * fix query params * remove unchanged files * this * update tsconfig
1298 lines
39 KiB
TypeScript
1298 lines
39 KiB
TypeScript
/**
|
|
* 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,
|
|
SupersetTheme,
|
|
FeatureFlag,
|
|
isFeatureEnabled,
|
|
} from '@superset-ui/core';
|
|
import React, {
|
|
FunctionComponent,
|
|
useEffect,
|
|
useState,
|
|
useReducer,
|
|
Reducer,
|
|
} from 'react';
|
|
import Tabs from 'src/components/Tabs';
|
|
import { Select } from 'src/common/components';
|
|
import Alert from 'src/components/Alert';
|
|
import Modal from 'src/components/Modal';
|
|
import Button from 'src/components/Button';
|
|
import IconButton from 'src/components/IconButton';
|
|
import InfoTooltip from 'src/components/InfoTooltip';
|
|
import withToasts from 'src/messageToasts/enhancers/withToasts';
|
|
import {
|
|
testDatabaseConnection,
|
|
useSingleViewResource,
|
|
useAvailableDatabases,
|
|
useDatabaseValidation,
|
|
getDatabaseImages,
|
|
getConnectionAlert,
|
|
} from 'src/views/CRUD/hooks';
|
|
import { useCommonConf } from 'src/views/CRUD/data/database/state';
|
|
import {
|
|
DatabaseObject,
|
|
DatabaseForm,
|
|
CONFIGURATION_METHOD,
|
|
CatalogObject,
|
|
} from 'src/views/CRUD/data/database/types';
|
|
import Loading from 'src/components/Loading';
|
|
import ExtraOptions from './ExtraOptions';
|
|
import SqlAlchemyForm from './SqlAlchemyForm';
|
|
import DatabaseConnectionForm from './DatabaseConnectionForm';
|
|
import {
|
|
antDErrorAlertStyles,
|
|
antDAlertStyles,
|
|
StyledAlertMargin,
|
|
antDModalNoPaddingStyles,
|
|
antDModalStyles,
|
|
antDTabsStyles,
|
|
buttonLinkStyles,
|
|
alchemyButtonLinkStyles,
|
|
TabHeader,
|
|
formHelperStyles,
|
|
formStyles,
|
|
StyledAlignment,
|
|
SelectDatabaseStyles,
|
|
infoTooltip,
|
|
StyledFooterButton,
|
|
StyledStickyHeader,
|
|
} from './styles';
|
|
import ModalHeader, { DOCUMENTATION_LINK } from './ModalHeader';
|
|
|
|
const engineSpecificAlertMapping = {
|
|
gsheets: {
|
|
message: 'Why do I need to create a database?',
|
|
description:
|
|
'To begin using your Google Sheets, you need to create a database first. ' +
|
|
'Databases are used as a way to identify ' +
|
|
'your data so that it can be queried and visualized. This ' +
|
|
'database will hold all of your individual Google Sheets ' +
|
|
'you choose to connect here.',
|
|
},
|
|
};
|
|
|
|
const errorAlertMapping = {
|
|
CONNECTION_MISSING_PARAMETERS_ERROR: {
|
|
message: 'Missing Required Fields',
|
|
description: 'Please complete all required fields.',
|
|
},
|
|
CONNECTION_INVALID_HOSTNAME_ERROR: {
|
|
message: 'Could not verify the host',
|
|
description:
|
|
'The host is invalid. Please verify that this field is entered correctly.',
|
|
},
|
|
CONNECTION_PORT_CLOSED_ERROR: {
|
|
message: 'Port is closed',
|
|
description: 'Please verify that port is open to connect.',
|
|
},
|
|
CONNECTION_INVALID_PORT_ERROR: {
|
|
message: 'Invalid Port Number',
|
|
description: 'The port must be a whole number less than or equal to 65535.',
|
|
},
|
|
CONNECTION_ACCESS_DENIED_ERROR: {
|
|
message: 'Invalid account information',
|
|
description: 'Either the username or password is incorrect.',
|
|
},
|
|
CONNECTION_INVALID_PASSWORD_ERROR: {
|
|
message: 'Invalid account information',
|
|
description: 'Either the username or password is incorrect.',
|
|
},
|
|
INVALID_PAYLOAD_SCHEMA_ERROR: {
|
|
message: 'Incorrect Fields',
|
|
description: 'Please make sure all fields are filled out correctly',
|
|
},
|
|
TABLE_DOES_NOT_EXIST_ERROR: {
|
|
message: 'URL could not be identified',
|
|
description:
|
|
'The URL could not be identified. Please check for typos and make sure that "Type of google sheet allowed" selection matches the input',
|
|
},
|
|
};
|
|
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 {
|
|
configMethodChange,
|
|
dbSelected,
|
|
editorChange,
|
|
fetched,
|
|
inputChange,
|
|
parametersChange,
|
|
reset,
|
|
textChange,
|
|
extraInputChange,
|
|
extraEditorChange,
|
|
addTableCatalogSheet,
|
|
removeTableCatalogSheet,
|
|
}
|
|
|
|
interface DBReducerPayloadType {
|
|
target?: string;
|
|
name: string;
|
|
json?: {};
|
|
type?: string;
|
|
checked?: boolean;
|
|
value?: string;
|
|
}
|
|
|
|
type DBReducerActionType =
|
|
| {
|
|
type:
|
|
| ActionType.extraEditorChange
|
|
| ActionType.extraInputChange
|
|
| ActionType.textChange
|
|
| ActionType.inputChange
|
|
| ActionType.editorChange
|
|
| ActionType.parametersChange;
|
|
payload: DBReducerPayloadType;
|
|
}
|
|
| {
|
|
type: ActionType.fetched;
|
|
payload: Partial<DatabaseObject>;
|
|
}
|
|
| {
|
|
type: ActionType.dbSelected;
|
|
payload: {
|
|
database_name?: string;
|
|
engine?: string;
|
|
configuration_method: CONFIGURATION_METHOD;
|
|
};
|
|
}
|
|
| {
|
|
type: ActionType.reset | ActionType.addTableCatalogSheet;
|
|
}
|
|
| {
|
|
type: ActionType.removeTableCatalogSheet;
|
|
payload: {
|
|
indexToDelete: number;
|
|
};
|
|
}
|
|
| {
|
|
type: ActionType.configMethodChange;
|
|
payload: {
|
|
database_name?: string;
|
|
engine?: string;
|
|
configuration_method: CONFIGURATION_METHOD;
|
|
};
|
|
};
|
|
|
|
function dbReducer(
|
|
state: Partial<DatabaseObject> | null,
|
|
action: DBReducerActionType,
|
|
): Partial<DatabaseObject> | null {
|
|
const trimmedState = {
|
|
...(state || {}),
|
|
};
|
|
let query = '';
|
|
let deserializeExtraJSON = {};
|
|
let extra_json: DatabaseObject['extra_json'];
|
|
|
|
switch (action.type) {
|
|
case ActionType.extraEditorChange:
|
|
return {
|
|
...trimmedState,
|
|
extra_json: {
|
|
...trimmedState.extra_json,
|
|
[action.payload.name]: action.payload.json,
|
|
},
|
|
};
|
|
case ActionType.extraInputChange:
|
|
if (
|
|
action.payload.name === 'schema_cache_timeout' ||
|
|
action.payload.name === 'table_cache_timeout'
|
|
) {
|
|
return {
|
|
...trimmedState,
|
|
extra_json: {
|
|
...trimmedState.extra_json,
|
|
metadata_cache_timeout: {
|
|
...trimmedState.extra_json?.metadata_cache_timeout,
|
|
[action.payload.name]: action.payload.value,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
if (action.payload.name === 'schemas_allowed_for_csv_upload') {
|
|
return {
|
|
...trimmedState,
|
|
extra_json: {
|
|
...trimmedState.extra_json,
|
|
schemas_allowed_for_csv_upload: (action.payload.value || '').split(
|
|
',',
|
|
),
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
...trimmedState,
|
|
extra_json: {
|
|
...trimmedState.extra_json,
|
|
[action.payload.name]:
|
|
action.payload.type === 'checkbox'
|
|
? action.payload.checked
|
|
: action.payload.value,
|
|
},
|
|
};
|
|
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.parametersChange:
|
|
if (
|
|
trimmedState.catalog !== undefined &&
|
|
action.payload.type?.startsWith('catalog')
|
|
) {
|
|
// Formatting wrapping google sheets table catalog
|
|
const idx = action.payload.type?.split('-')[1];
|
|
const catalogToUpdate = trimmedState?.catalog[idx] || {};
|
|
catalogToUpdate[action.payload.name] = action.payload.value;
|
|
|
|
const paramatersCatalog = {};
|
|
// eslint-disable-next-line array-callback-return
|
|
trimmedState.catalog?.map((item: CatalogObject) => {
|
|
paramatersCatalog[item.name] = item.value;
|
|
});
|
|
|
|
return {
|
|
...trimmedState,
|
|
parameters: {
|
|
...trimmedState.parameters,
|
|
catalog: paramatersCatalog,
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
...trimmedState,
|
|
parameters: {
|
|
...trimmedState.parameters,
|
|
[action.payload.name]: action.payload.value,
|
|
},
|
|
};
|
|
case ActionType.addTableCatalogSheet:
|
|
if (trimmedState.catalog !== undefined) {
|
|
return {
|
|
...trimmedState,
|
|
catalog: [...trimmedState.catalog, { name: '', value: '' }],
|
|
};
|
|
}
|
|
return {
|
|
...trimmedState,
|
|
catalog: [{ name: '', value: '' }],
|
|
};
|
|
case ActionType.removeTableCatalogSheet:
|
|
trimmedState.catalog?.splice(action.payload.indexToDelete, 1);
|
|
return {
|
|
...trimmedState,
|
|
};
|
|
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:
|
|
// convert all the keys in this payload into strings
|
|
if (action.payload.extra) {
|
|
extra_json = {
|
|
...JSON.parse(action.payload.extra || ''),
|
|
} as DatabaseObject['extra_json'];
|
|
|
|
deserializeExtraJSON = {
|
|
...JSON.parse(action.payload.extra || ''),
|
|
metadata_params: JSON.stringify(extra_json?.metadata_params),
|
|
engine_params: JSON.stringify(extra_json?.engine_params),
|
|
schemas_allowed_for_csv_upload:
|
|
extra_json?.schemas_allowed_for_csv_upload,
|
|
};
|
|
}
|
|
|
|
if (
|
|
action.payload.backend === 'bigquery' &&
|
|
action.payload.configuration_method ===
|
|
CONFIGURATION_METHOD.DYNAMIC_FORM
|
|
) {
|
|
// convert query into URI params string
|
|
query = new URLSearchParams(
|
|
action?.payload?.parameters?.query as string,
|
|
).toString();
|
|
|
|
return {
|
|
...action.payload,
|
|
engine: action.payload.backend,
|
|
configuration_method: action.payload.configuration_method,
|
|
extra_json: deserializeExtraJSON,
|
|
parameters: {
|
|
credentials_info: JSON.stringify(
|
|
action.payload?.parameters?.credentials_info || '',
|
|
),
|
|
query,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (
|
|
action.payload.backend === 'gsheets' &&
|
|
action.payload.configuration_method ===
|
|
CONFIGURATION_METHOD.DYNAMIC_FORM &&
|
|
extra_json?.engine_params?.catalog !== undefined
|
|
) {
|
|
// pull catalog from engine params
|
|
const engineParamsCatalog = extra_json?.engine_params?.catalog;
|
|
|
|
return {
|
|
...action.payload,
|
|
engine: action.payload.backend,
|
|
configuration_method: action.payload.configuration_method,
|
|
extra_json: deserializeExtraJSON,
|
|
catalog: Object.keys(engineParamsCatalog).map(e => ({
|
|
name: e,
|
|
value: engineParamsCatalog[e],
|
|
})),
|
|
} as DatabaseObject;
|
|
}
|
|
|
|
if (action.payload?.parameters?.query) {
|
|
// convert query into URI params string
|
|
query = new URLSearchParams(
|
|
action.payload.parameters.query as string,
|
|
).toString();
|
|
|
|
return {
|
|
...action.payload,
|
|
encrypted_extra: action.payload.encrypted_extra || '',
|
|
engine: action.payload.backend || trimmedState.engine,
|
|
configuration_method: action.payload.configuration_method,
|
|
extra_json: deserializeExtraJSON,
|
|
parameters: {
|
|
...action.payload.parameters,
|
|
query,
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
...action.payload,
|
|
encrypted_extra: action.payload.encrypted_extra || '',
|
|
engine: action.payload.backend || trimmedState.engine,
|
|
configuration_method: action.payload.configuration_method,
|
|
extra_json: deserializeExtraJSON,
|
|
parameters: {
|
|
...action.payload.parameters,
|
|
},
|
|
};
|
|
|
|
case ActionType.dbSelected:
|
|
return {
|
|
...action.payload,
|
|
};
|
|
case ActionType.configMethodChange:
|
|
return {
|
|
...action.payload,
|
|
};
|
|
case ActionType.reset:
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const DEFAULT_TAB_KEY = '1';
|
|
|
|
const serializeExtra = (extraJson: DatabaseObject['extra_json']) =>
|
|
JSON.stringify({
|
|
...extraJson,
|
|
metadata_params: JSON.parse((extraJson?.metadata_params as string) || '{}'),
|
|
engine_params: JSON.parse(
|
|
((extraJson?.engine_params as unknown) as string) || '{}',
|
|
),
|
|
schemas_allowed_for_csv_upload: (
|
|
extraJson?.schemas_allowed_for_csv_upload || []
|
|
).filter(schema => schema !== ''),
|
|
});
|
|
|
|
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 [availableDbs, getAvailableDbs] = useAvailableDatabases();
|
|
const [
|
|
validationErrors,
|
|
getValidation,
|
|
setValidationErrors,
|
|
] = useDatabaseValidation();
|
|
const [hasConnectedDb, setHasConnectedDb] = useState<boolean>(false);
|
|
const [dbName, setDbName] = useState('');
|
|
const [editNewDb, setEditNewDb] = useState<boolean>(false);
|
|
const [isLoading, setLoading] = useState<boolean>(false);
|
|
const conf = useCommonConf();
|
|
const dbImages = getDatabaseImages();
|
|
const connectionAlert = getConnectionAlert();
|
|
const isEditMode = !!databaseId;
|
|
const sslForced = isFeatureEnabled(
|
|
FeatureFlag.FORCE_DATABASE_CONNECTIONS_SSL,
|
|
);
|
|
const hasAlert =
|
|
connectionAlert || !!(db?.engine && engineSpecificAlertMapping[db.engine]);
|
|
const useSqlAlchemyForm =
|
|
db?.configuration_method === CONFIGURATION_METHOD.SQLALCHEMY_URI;
|
|
const useTabLayout = isEditMode || useSqlAlchemyForm;
|
|
// Database fetch logic
|
|
const {
|
|
state: { loading: dbLoading, resource: dbFetched, error: dbErrors },
|
|
fetchResource,
|
|
createResource,
|
|
updateResource,
|
|
clearError,
|
|
} = useSingleViewResource<DatabaseObject>(
|
|
'database',
|
|
t('database'),
|
|
addDangerToast,
|
|
);
|
|
const isDynamic = (engine: string | undefined) =>
|
|
availableDbs?.databases.filter(
|
|
(DB: DatabaseObject) => DB.backend === engine || DB.engine === engine,
|
|
)[0].parameters !== undefined;
|
|
const showDBError = validationErrors || dbErrors;
|
|
const isEmpty = (data?: Object | null) =>
|
|
data && Object.keys(data).length === 0;
|
|
|
|
const dbModel: DatabaseForm =
|
|
availableDbs?.databases?.find(
|
|
(available: { engine: string | undefined }) =>
|
|
// TODO: we need a centralized engine in one place
|
|
available.engine === (isEditMode ? db?.backend : db?.engine),
|
|
) || {};
|
|
|
|
// 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: serializeExtra(db?.extra_json) || undefined,
|
|
encrypted_extra: db?.encrypted_extra || '',
|
|
server_cert: db?.server_cert || undefined,
|
|
};
|
|
|
|
testDatabaseConnection(connection, addDangerToast, addSuccessToast);
|
|
};
|
|
|
|
const onClose = () => {
|
|
setDB({ type: ActionType.reset });
|
|
setHasConnectedDb(false);
|
|
setValidationErrors(null); // reset validation errors on close
|
|
clearError();
|
|
setEditNewDb(false);
|
|
onHide();
|
|
};
|
|
|
|
const onSave = async () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { id, ...update } = db || {};
|
|
|
|
// Clone DB object
|
|
const dbToUpdate = JSON.parse(JSON.stringify(update));
|
|
|
|
if (dbToUpdate.configuration_method === CONFIGURATION_METHOD.DYNAMIC_FORM) {
|
|
if (dbToUpdate?.parameters?.query) {
|
|
// convert query params into dictionary
|
|
const urlParams = new URLSearchParams(dbToUpdate?.parameters?.query);
|
|
dbToUpdate.parameters.query = Object.fromEntries(urlParams);
|
|
} else if (
|
|
dbToUpdate?.parameters?.query === '' &&
|
|
'query' in dbModel.parameters.properties
|
|
) {
|
|
dbToUpdate.parameters.query = {};
|
|
}
|
|
|
|
// Validate DB before saving
|
|
await getValidation(dbToUpdate, true);
|
|
if (validationErrors && !isEmpty(validationErrors)) {
|
|
return;
|
|
}
|
|
|
|
const engine = dbToUpdate.backend || dbToUpdate.engine;
|
|
if (engine === 'bigquery' && dbToUpdate.parameters?.credentials_info) {
|
|
// wrap encrypted_extra in credentials_info only for BigQuery
|
|
if (
|
|
dbToUpdate.parameters?.credentials_info &&
|
|
typeof dbToUpdate.parameters?.credentials_info === 'object' &&
|
|
dbToUpdate.parameters?.credentials_info.constructor === Object
|
|
) {
|
|
// Don't cast if object
|
|
dbToUpdate.encrypted_extra = JSON.stringify({
|
|
credentials_info: dbToUpdate.parameters?.credentials_info,
|
|
});
|
|
|
|
// Convert credentials info string before updating
|
|
dbToUpdate.parameters.credentials_info = JSON.stringify(
|
|
dbToUpdate.parameters.credentials_info,
|
|
);
|
|
} else {
|
|
dbToUpdate.encrypted_extra = JSON.stringify({
|
|
credentials_info: JSON.parse(
|
|
dbToUpdate.parameters?.credentials_info,
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (dbToUpdate?.parameters?.catalog) {
|
|
// need to stringify gsheets catalog to allow it to be seralized
|
|
dbToUpdate.extra_json = {
|
|
engine_params: JSON.stringify({
|
|
catalog: dbToUpdate.parameters.catalog,
|
|
}),
|
|
};
|
|
}
|
|
|
|
if (dbToUpdate?.extra_json) {
|
|
// convert extra_json to back to string
|
|
dbToUpdate.extra = serializeExtra(dbToUpdate?.extra_json);
|
|
}
|
|
|
|
if (db?.id) {
|
|
setLoading(true);
|
|
const result = await updateResource(
|
|
db.id as number,
|
|
dbToUpdate as DatabaseObject,
|
|
dbToUpdate.configuration_method === CONFIGURATION_METHOD.DYNAMIC_FORM, // onShow toast on SQLA Forms
|
|
);
|
|
if (result) {
|
|
if (onDatabaseAdd) {
|
|
onDatabaseAdd();
|
|
}
|
|
if (!editNewDb) {
|
|
onClose();
|
|
}
|
|
}
|
|
} else if (db) {
|
|
// Create
|
|
setLoading(true);
|
|
const dbId = await createResource(
|
|
dbToUpdate as DatabaseObject,
|
|
dbToUpdate.configuration_method === CONFIGURATION_METHOD.DYNAMIC_FORM, // onShow toast on SQLA Forms
|
|
);
|
|
if (dbId) {
|
|
setHasConnectedDb(true);
|
|
if (onDatabaseAdd) {
|
|
onDatabaseAdd();
|
|
}
|
|
if (useTabLayout) {
|
|
// tab layout only has one step
|
|
// so it should close immediately on save
|
|
onClose();
|
|
}
|
|
}
|
|
}
|
|
setEditNewDb(false);
|
|
setLoading(false);
|
|
};
|
|
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
const setDatabaseModel = (database_name: string) => {
|
|
const selectedDbModel = availableDbs?.databases.filter(
|
|
(db: DatabaseObject) => db.name === database_name,
|
|
)[0];
|
|
const { engine, parameters } = selectedDbModel;
|
|
const isDynamic = parameters !== undefined;
|
|
setDB({
|
|
type: ActionType.dbSelected,
|
|
payload: {
|
|
database_name,
|
|
configuration_method: isDynamic
|
|
? CONFIGURATION_METHOD.DYNAMIC_FORM
|
|
: CONFIGURATION_METHOD.SQLALCHEMY_URI,
|
|
engine,
|
|
},
|
|
});
|
|
setDB({ type: ActionType.addTableCatalogSheet });
|
|
};
|
|
|
|
const renderAvailableSelector = () => (
|
|
<div className="available">
|
|
<h4 className="available-label">
|
|
Or choose from a list of other databases we support:
|
|
</h4>
|
|
<div className="control-label">Supported databases</div>
|
|
<Select
|
|
className="available-select"
|
|
onChange={setDatabaseModel}
|
|
placeholder="Choose a database..."
|
|
>
|
|
{[...(availableDbs?.databases || [])]
|
|
?.sort((a: DatabaseForm, b: DatabaseForm) =>
|
|
a.name.localeCompare(b.name),
|
|
)
|
|
.map((database: DatabaseForm) => (
|
|
<Select.Option value={database.name} key={database.name}>
|
|
{database.name}
|
|
</Select.Option>
|
|
))}
|
|
</Select>
|
|
<Alert
|
|
showIcon
|
|
closable={false}
|
|
css={(theme: SupersetTheme) => antDAlertStyles(theme)}
|
|
type="info"
|
|
message={
|
|
connectionAlert?.ADD_DATABASE?.message ||
|
|
t('Want to add a new database?')
|
|
}
|
|
description={
|
|
connectionAlert?.ADD_DATABASE ? (
|
|
<>
|
|
Any databases that allow connections via SQL Alchemy URIs can be
|
|
added.{' '}
|
|
<a
|
|
href={connectionAlert?.ADD_DATABASE.contact_link}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
{connectionAlert?.ADD_DATABASE.contact_description_link}
|
|
</a>{' '}
|
|
{connectionAlert?.ADD_DATABASE.description}
|
|
</>
|
|
) : (
|
|
<>
|
|
Any databases that allow connections via SQL Alchemy URIs can be
|
|
added. Learn about how to connect a database driver{' '}
|
|
<a
|
|
href={DOCUMENTATION_LINK}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
here
|
|
</a>
|
|
.
|
|
</>
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
const renderPreferredSelector = () => (
|
|
<div className="preferred">
|
|
{availableDbs?.databases
|
|
?.filter((db: DatabaseForm) => db.preferred)
|
|
.map((database: DatabaseForm) => (
|
|
<IconButton
|
|
className="preferred-item"
|
|
onClick={() => setDatabaseModel(database.name)}
|
|
buttonText={database.name}
|
|
icon={dbImages?.[database.engine]}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
const handleBackButtonOnFinish = () => {
|
|
if (dbFetched) {
|
|
fetchResource(dbFetched.id as number);
|
|
}
|
|
setEditNewDb(true);
|
|
};
|
|
|
|
const handleBackButtonOnConnect = () => {
|
|
if (editNewDb) {
|
|
setHasConnectedDb(false);
|
|
}
|
|
setDB({ type: ActionType.reset });
|
|
};
|
|
|
|
const renderModalFooter = () => {
|
|
if (db) {
|
|
// if db show back + connenct
|
|
if (!hasConnectedDb || editNewDb) {
|
|
return (
|
|
<>
|
|
<StyledFooterButton key="back" onClick={handleBackButtonOnConnect}>
|
|
Back
|
|
</StyledFooterButton>
|
|
<StyledFooterButton
|
|
key="submit"
|
|
buttonStyle="primary"
|
|
onClick={onSave}
|
|
>
|
|
Connect
|
|
</StyledFooterButton>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<StyledFooterButton key="back" onClick={handleBackButtonOnFinish}>
|
|
Back
|
|
</StyledFooterButton>
|
|
<StyledFooterButton
|
|
key="submit"
|
|
buttonStyle="primary"
|
|
onClick={onSave}
|
|
data-test="modal-confirm-button"
|
|
>
|
|
Finish
|
|
</StyledFooterButton>
|
|
</>
|
|
);
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const renderEditModalFooter = () => (
|
|
<>
|
|
<StyledFooterButton key="close" onClick={onClose}>
|
|
Close
|
|
</StyledFooterButton>
|
|
<StyledFooterButton key="submit" buttonStyle="primary" onClick={onSave}>
|
|
Finish
|
|
</StyledFooterButton>
|
|
</>
|
|
);
|
|
useEffect(() => {
|
|
if (show) {
|
|
setTabKey(DEFAULT_TAB_KEY);
|
|
getAvailableDbs();
|
|
setLoading(true);
|
|
}
|
|
if (databaseId && show) {
|
|
fetchDB();
|
|
}
|
|
}, [show, databaseId]);
|
|
|
|
useEffect(() => {
|
|
if (dbFetched) {
|
|
setDB({
|
|
type: ActionType.fetched,
|
|
payload: dbFetched,
|
|
});
|
|
// keep a copy of the name separate for display purposes
|
|
// because it shouldn't change when the form is updated
|
|
setDbName(dbFetched.database_name);
|
|
}
|
|
}, [dbFetched]);
|
|
|
|
useEffect(() => {
|
|
if (isLoading) {
|
|
setLoading(false);
|
|
}
|
|
}, [availableDbs]);
|
|
|
|
const tabChange = (key: string) => {
|
|
setTabKey(key);
|
|
};
|
|
|
|
const renderStepTwoAlert = () =>
|
|
db?.engine && (
|
|
<StyledAlertMargin>
|
|
<Alert
|
|
closable={false}
|
|
css={(theme: SupersetTheme) => antDAlertStyles(theme)}
|
|
type="info"
|
|
showIcon
|
|
message={
|
|
engineSpecificAlertMapping[db.engine]?.message ||
|
|
connectionAlert?.DEFAULT?.message
|
|
}
|
|
description={
|
|
engineSpecificAlertMapping[db.engine]?.description ||
|
|
connectionAlert?.DEFAULT?.description
|
|
}
|
|
/>
|
|
</StyledAlertMargin>
|
|
);
|
|
|
|
const errorAlert = () => {
|
|
if (
|
|
isEmpty(dbErrors) ||
|
|
(isEmpty(validationErrors) &&
|
|
!(validationErrors?.error_type in errorAlertMapping))
|
|
) {
|
|
return <></>;
|
|
}
|
|
|
|
if (validationErrors) {
|
|
return (
|
|
<Alert
|
|
type="error"
|
|
css={(theme: SupersetTheme) => antDErrorAlertStyles(theme)}
|
|
message={
|
|
errorAlertMapping[validationErrors?.error_type]?.message ||
|
|
validationErrors?.error_type
|
|
}
|
|
description={
|
|
errorAlertMapping[validationErrors?.error_type]?.description ||
|
|
JSON.stringify(validationErrors)
|
|
}
|
|
showIcon
|
|
closable={false}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const message: Array<string> = Object.values(dbErrors);
|
|
return (
|
|
<Alert
|
|
type="error"
|
|
css={(theme: SupersetTheme) => antDErrorAlertStyles(theme)}
|
|
message="Database Creation Error"
|
|
description={message[0]}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const renderFinishState = () => {
|
|
if (!editNewDb) {
|
|
return (
|
|
<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)
|
|
}
|
|
onExtraInputChange={({ target }: { target: HTMLInputElement }) => {
|
|
onChange(ActionType.extraInputChange, {
|
|
type: target.type,
|
|
name: target.name,
|
|
checked: target.checked,
|
|
value: target.value,
|
|
});
|
|
}}
|
|
onExtraEditorChange={(payload: { name: string; json: any }) =>
|
|
onChange(ActionType.extraEditorChange, payload)
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<DatabaseConnectionForm
|
|
isEditMode
|
|
sslForced={sslForced}
|
|
dbModel={dbModel}
|
|
db={db as DatabaseObject}
|
|
onParametersChange={({ target }: { target: HTMLInputElement }) =>
|
|
onChange(ActionType.parametersChange, {
|
|
type: target.type,
|
|
name: target.name,
|
|
checked: target.checked,
|
|
value: target.value,
|
|
})
|
|
}
|
|
onChange={({ target }: { target: HTMLInputElement }) =>
|
|
onChange(ActionType.textChange, {
|
|
name: target.name,
|
|
value: target.value,
|
|
})
|
|
}
|
|
onAddTableCatalog={() =>
|
|
setDB({ type: ActionType.addTableCatalogSheet })
|
|
}
|
|
onRemoveTableCatalog={(idx: number) =>
|
|
setDB({
|
|
type: ActionType.removeTableCatalogSheet,
|
|
payload: { indexToDelete: idx },
|
|
})
|
|
}
|
|
getValidation={() => getValidation(db)}
|
|
validationErrors={validationErrors}
|
|
/>
|
|
);
|
|
};
|
|
|
|
return useTabLayout ? (
|
|
<Modal
|
|
css={(theme: SupersetTheme) => [
|
|
antDTabsStyles,
|
|
antDModalNoPaddingStyles,
|
|
antDModalStyles(theme),
|
|
formHelperStyles(theme),
|
|
formStyles(theme),
|
|
]}
|
|
name="database"
|
|
data-test="database-modal"
|
|
onHandledPrimaryAction={onSave}
|
|
onHide={onClose}
|
|
primaryButtonName={isEditMode ? t('Save') : t('Connect')}
|
|
width="500px"
|
|
centered
|
|
show={show}
|
|
title={
|
|
<h4>{isEditMode ? t('Edit database') : t('Connect a database')}</h4>
|
|
}
|
|
footer={isEditMode ? renderEditModalFooter() : renderModalFooter()}
|
|
>
|
|
<StyledStickyHeader>
|
|
<TabHeader>
|
|
<ModalHeader
|
|
isLoading={isLoading}
|
|
isEditMode={isEditMode}
|
|
useSqlAlchemyForm={useSqlAlchemyForm}
|
|
hasConnectedDb={hasConnectedDb}
|
|
db={db}
|
|
dbName={dbName}
|
|
dbModel={dbModel}
|
|
/>
|
|
</TabHeader>
|
|
</StyledStickyHeader>
|
|
<Tabs
|
|
defaultActiveKey={DEFAULT_TAB_KEY}
|
|
activeKey={tabKey}
|
|
onTabClick={tabChange}
|
|
animated={{ inkBar: true, tabPane: true }}
|
|
>
|
|
<Tabs.TabPane tab={<span>{t('Basic')}</span>} key="1">
|
|
{useSqlAlchemyForm ? (
|
|
<StyledAlignment>
|
|
<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}
|
|
isEditMode={isEditMode}
|
|
/>
|
|
{isDynamic(db?.backend || db?.engine) && !isEditMode && (
|
|
<div css={(theme: SupersetTheme) => infoTooltip(theme)}>
|
|
<Button
|
|
buttonStyle="link"
|
|
onClick={() =>
|
|
setDB({
|
|
type: ActionType.configMethodChange,
|
|
payload: {
|
|
database_name: db?.database_name,
|
|
configuration_method:
|
|
CONFIGURATION_METHOD.DYNAMIC_FORM,
|
|
engine: db?.engine,
|
|
},
|
|
})
|
|
}
|
|
css={theme => alchemyButtonLinkStyles(theme)}
|
|
>
|
|
Connect this database using the dynamic form instead
|
|
</Button>
|
|
<InfoTooltip
|
|
tooltip={t(
|
|
'Click this link to switch to an alternate form that exposes only the required fields needed to connect this database.',
|
|
)}
|
|
viewBox="0 -6 24 24"
|
|
/>
|
|
</div>
|
|
)}
|
|
</StyledAlignment>
|
|
) : (
|
|
<DatabaseConnectionForm
|
|
isEditMode
|
|
sslForced={sslForced}
|
|
dbModel={dbModel}
|
|
db={db as DatabaseObject}
|
|
onParametersChange={({ target }: { target: HTMLInputElement }) =>
|
|
onChange(ActionType.parametersChange, {
|
|
type: target.type,
|
|
name: target.name,
|
|
checked: target.checked,
|
|
value: target.value,
|
|
})
|
|
}
|
|
onChange={({ target }: { target: HTMLInputElement }) =>
|
|
onChange(ActionType.textChange, {
|
|
name: target.name,
|
|
value: target.value,
|
|
})
|
|
}
|
|
onAddTableCatalog={() =>
|
|
setDB({ type: ActionType.addTableCatalogSheet })
|
|
}
|
|
onRemoveTableCatalog={(idx: number) =>
|
|
setDB({
|
|
type: ActionType.removeTableCatalogSheet,
|
|
payload: { indexToDelete: idx },
|
|
})
|
|
}
|
|
getValidation={() => getValidation(db)}
|
|
validationErrors={validationErrors}
|
|
/>
|
|
)}
|
|
{!isEditMode && (
|
|
<StyledAlertMargin>
|
|
<Alert
|
|
closable={false}
|
|
css={(theme: SupersetTheme) => antDAlertStyles(theme)}
|
|
message="Additional fields may be required"
|
|
showIcon
|
|
description={
|
|
<>
|
|
Select databases require additional fields to be completed
|
|
in the Advanced tab to successfully connect the database.
|
|
Learn what requirements your databases has{' '}
|
|
<a
|
|
href={DOCUMENTATION_LINK}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="additional-fields-alert-description"
|
|
>
|
|
here
|
|
</a>
|
|
.
|
|
</>
|
|
}
|
|
type="info"
|
|
/>
|
|
</StyledAlertMargin>
|
|
)}
|
|
</Tabs.TabPane>
|
|
<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)
|
|
}
|
|
onExtraInputChange={({ target }: { target: HTMLInputElement }) => {
|
|
onChange(ActionType.extraInputChange, {
|
|
type: target.type,
|
|
name: target.name,
|
|
checked: target.checked,
|
|
value: target.value,
|
|
});
|
|
}}
|
|
onExtraEditorChange={(payload: { name: string; json: any }) => {
|
|
onChange(ActionType.extraEditorChange, payload);
|
|
}}
|
|
/>
|
|
{showDBError && errorAlert()}
|
|
</Tabs.TabPane>
|
|
</Tabs>
|
|
</Modal>
|
|
) : (
|
|
<Modal
|
|
css={(theme: SupersetTheme) => [
|
|
antDModalNoPaddingStyles,
|
|
antDModalStyles(theme),
|
|
formHelperStyles(theme),
|
|
formStyles(theme),
|
|
]}
|
|
name="database"
|
|
onHandledPrimaryAction={onSave}
|
|
onHide={onClose}
|
|
primaryButtonName={hasConnectedDb ? t('Finish') : t('Connect')}
|
|
width="500px"
|
|
centered
|
|
show={show}
|
|
title={<h4>{t('Connect a database')}</h4>}
|
|
footer={renderModalFooter()}
|
|
>
|
|
{hasConnectedDb ? (
|
|
<>
|
|
<ModalHeader
|
|
isLoading={isLoading}
|
|
isEditMode={isEditMode}
|
|
useSqlAlchemyForm={useSqlAlchemyForm}
|
|
hasConnectedDb={hasConnectedDb}
|
|
db={db}
|
|
dbName={dbName}
|
|
dbModel={dbModel}
|
|
editNewDb={editNewDb}
|
|
/>
|
|
{renderFinishState()}
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* Dyanmic Form Step 1 */}
|
|
{!isLoading &&
|
|
(!db ? (
|
|
<SelectDatabaseStyles>
|
|
<ModalHeader
|
|
isLoading={isLoading}
|
|
isEditMode={isEditMode}
|
|
useSqlAlchemyForm={useSqlAlchemyForm}
|
|
hasConnectedDb={hasConnectedDb}
|
|
db={db}
|
|
dbName={dbName}
|
|
dbModel={dbModel}
|
|
/>
|
|
{renderPreferredSelector()}
|
|
{renderAvailableSelector()}
|
|
</SelectDatabaseStyles>
|
|
) : (
|
|
<>
|
|
<ModalHeader
|
|
isLoading={isLoading}
|
|
isEditMode={isEditMode}
|
|
useSqlAlchemyForm={useSqlAlchemyForm}
|
|
hasConnectedDb={hasConnectedDb}
|
|
db={db}
|
|
dbName={dbName}
|
|
dbModel={dbModel}
|
|
/>
|
|
{hasAlert && renderStepTwoAlert()}
|
|
<DatabaseConnectionForm
|
|
db={db}
|
|
sslForced={sslForced}
|
|
dbModel={dbModel}
|
|
onAddTableCatalog={() => {
|
|
setDB({ type: ActionType.addTableCatalogSheet });
|
|
}}
|
|
onRemoveTableCatalog={(idx: number) => {
|
|
setDB({
|
|
type: ActionType.removeTableCatalogSheet,
|
|
payload: { indexToDelete: idx },
|
|
});
|
|
}}
|
|
onParametersChange={({
|
|
target,
|
|
}: {
|
|
target: HTMLInputElement;
|
|
}) =>
|
|
onChange(ActionType.parametersChange, {
|
|
type: target.type,
|
|
name: target.name,
|
|
checked: target.checked,
|
|
value: target.value,
|
|
})
|
|
}
|
|
onChange={({ target }: { target: HTMLInputElement }) =>
|
|
onChange(ActionType.textChange, {
|
|
name: target.name,
|
|
value: target.value,
|
|
})
|
|
}
|
|
getValidation={() => getValidation(db)}
|
|
validationErrors={validationErrors}
|
|
/>
|
|
<div css={(theme: SupersetTheme) => infoTooltip(theme)}>
|
|
<Button
|
|
data-test="sqla-connect-btn"
|
|
buttonStyle="link"
|
|
onClick={() =>
|
|
setDB({
|
|
type: ActionType.configMethodChange,
|
|
payload: {
|
|
engine: db.engine,
|
|
configuration_method:
|
|
CONFIGURATION_METHOD.SQLALCHEMY_URI,
|
|
database_name: db.database_name,
|
|
},
|
|
})
|
|
}
|
|
css={buttonLinkStyles}
|
|
>
|
|
Connect this database with a SQLAlchemy URI string instead
|
|
</Button>
|
|
<InfoTooltip
|
|
tooltip={t(
|
|
'Click this link to switch to an alternate form that allows you to input the SQLAlchemy URL for this database manually.',
|
|
)}
|
|
viewBox="0 -6 24 24"
|
|
/>
|
|
</div>
|
|
{/* Step 2 */}
|
|
{showDBError && errorAlert()}
|
|
</>
|
|
))}
|
|
</>
|
|
)}
|
|
{isLoading && <Loading />}
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default withToasts(DatabaseModal);
|