mirror of
https://github.com/apache/superset.git
synced 2026-07-12 17:55:38 +00:00
2414 lines
74 KiB
TypeScript
2414 lines
74 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 } from '@apache-superset/core/translation';
|
|
import { getExtensionsRegistry } from '@superset-ui/core';
|
|
import { Alert } from '@apache-superset/core/components';
|
|
import { styled, SupersetTheme } from '@apache-superset/core/theme';
|
|
|
|
import {
|
|
FunctionComponent,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
useReducer,
|
|
Reducer,
|
|
useCallback,
|
|
ChangeEvent,
|
|
} from 'react';
|
|
import { CheckboxChangeEvent } from '@superset-ui/core/components/Checkbox/types';
|
|
|
|
import { useHistory } from 'react-router-dom';
|
|
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
|
|
import Tabs from '@superset-ui/core/components/Tabs';
|
|
import {
|
|
Button,
|
|
Icons,
|
|
LabeledErrorBoundInput as ValidatedInput,
|
|
Modal,
|
|
Select,
|
|
IconButton,
|
|
InfoTooltip,
|
|
Loading,
|
|
Upload,
|
|
type UploadChangeParam,
|
|
type UploadFile,
|
|
FormLabel,
|
|
} from '@superset-ui/core/components';
|
|
import { ErrorAlert, ErrorMessageWithStackTrace } from 'src/components';
|
|
import withToasts from 'src/components/MessageToasts/withToasts';
|
|
import {
|
|
testDatabaseConnection,
|
|
useSingleViewResource,
|
|
useAvailableDatabases,
|
|
useDatabaseValidation,
|
|
getDatabaseImages,
|
|
getConnectionAlert,
|
|
useImportResource,
|
|
} from 'src/views/CRUD/hooks';
|
|
import { FileEncryptedExtraFields } from 'src/views/CRUD/types';
|
|
import { useCommonConf } from 'src/features/databases/state';
|
|
import { isEmpty, pick } from 'lodash';
|
|
import { OnlyKeyWithType } from 'src/utils/types';
|
|
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
|
|
import {
|
|
DatabaseObject,
|
|
DatabaseForm,
|
|
ConfigurationMethod,
|
|
CatalogObject,
|
|
Engines,
|
|
ExtraJson,
|
|
CustomTextType,
|
|
DatabaseParameters,
|
|
} from '../types';
|
|
import ExtraOptions from './ExtraOptions';
|
|
import SqlAlchemyForm from './SqlAlchemyForm';
|
|
import DatabaseConnectionForm from './DatabaseConnectionForm';
|
|
import {
|
|
antDAlertStyles,
|
|
antdWarningAlertStyles,
|
|
StyledAlertMargin,
|
|
antDModalNoPaddingStyles,
|
|
antDModalStyles,
|
|
antDTabsStyles,
|
|
buttonLinkStyles,
|
|
importDbButtonLinkStyles,
|
|
alchemyButtonLinkStyles,
|
|
TabHeader,
|
|
formHelperStyles,
|
|
formStyles,
|
|
StyledAlignment,
|
|
SelectDatabaseStyles,
|
|
infoTooltip,
|
|
StyledFooterButton,
|
|
StyledStickyHeader,
|
|
formScrollableStyles,
|
|
StyledUploadWrapper,
|
|
} from './styles';
|
|
import ModalHeader, { DOCUMENTATION_LINK } from './ModalHeader';
|
|
import SSHTunnelForm from './SSHTunnelForm';
|
|
import SSHTunnelSwitch from './SSHTunnelSwitch';
|
|
|
|
const extensionsRegistry = getExtensionsRegistry();
|
|
|
|
const DEFAULT_EXTRA = JSON.stringify({ allows_virtual_table_explore: true });
|
|
|
|
const TABS_KEYS = {
|
|
BASIC: 'basic',
|
|
ADVANCED: 'advanced',
|
|
};
|
|
|
|
const engineSpecificAlertMapping = {
|
|
[Engines.GSheet]: {
|
|
message: t('Why do I need to create a database?'),
|
|
description: t(
|
|
'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 TabsStyled = styled(Tabs)`
|
|
.ant-tabs-content {
|
|
width: 100%;
|
|
overflow: inherit;
|
|
|
|
& > .ant-tabs-tabpane {
|
|
position: relative;
|
|
}
|
|
}
|
|
`;
|
|
|
|
const ErrorAlertContainer = styled.div`
|
|
${({ theme }) => `
|
|
margin: ${theme.sizeUnit * 8}px ${theme.sizeUnit * 4}px;
|
|
`};
|
|
`;
|
|
|
|
const SSHTunnelContainer = styled.div`
|
|
${({ theme }) => `
|
|
padding: 0px ${theme.sizeUnit * 4}px;
|
|
`};
|
|
`;
|
|
|
|
export interface DatabaseModalProps {
|
|
addDangerToast: (msg: string) => void;
|
|
addSuccessToast: (msg: string) => void;
|
|
onDatabaseAdd?: (database?: DatabaseObject) => void;
|
|
onHide: () => void;
|
|
show: boolean;
|
|
databaseId: number | undefined; // If included, will go into edit mode
|
|
dbEngine: string | undefined; // if included goto step 2 with engine already set
|
|
}
|
|
|
|
export enum ActionType {
|
|
AddTableCatalogSheet,
|
|
ConfigMethodChange,
|
|
DbSelected,
|
|
EditorChange,
|
|
ExtraEditorChange,
|
|
ExtraInputChange,
|
|
EncryptedExtraInputChange,
|
|
ClearEncryptedExtraKey,
|
|
Fetched,
|
|
InputChange,
|
|
ParametersChange,
|
|
QueryChange,
|
|
RemoveTableCatalogSheet,
|
|
Reset,
|
|
TextChange,
|
|
ParametersSSHTunnelChange,
|
|
SetSSHTunnelLoginMethod,
|
|
RemoveSSHTunnelConfig,
|
|
}
|
|
|
|
export enum AuthType {
|
|
Password,
|
|
PrivateKey,
|
|
}
|
|
|
|
interface DBReducerPayloadType {
|
|
target?: string;
|
|
name: string;
|
|
json?: string;
|
|
type?: string;
|
|
checked?: boolean;
|
|
value?: string;
|
|
}
|
|
|
|
export type DBReducerActionType =
|
|
| {
|
|
type:
|
|
| ActionType.ExtraEditorChange
|
|
| ActionType.ExtraInputChange
|
|
| ActionType.EncryptedExtraInputChange
|
|
| ActionType.ClearEncryptedExtraKey
|
|
| ActionType.TextChange
|
|
| ActionType.QueryChange
|
|
| ActionType.InputChange
|
|
| ActionType.EditorChange
|
|
| ActionType.ParametersChange
|
|
| ActionType.ParametersSSHTunnelChange;
|
|
payload: DBReducerPayloadType;
|
|
}
|
|
| {
|
|
type: ActionType.Fetched;
|
|
payload: Partial<DatabaseObject>;
|
|
}
|
|
| {
|
|
type: ActionType.DbSelected;
|
|
payload: {
|
|
database_name?: string;
|
|
engine?: string;
|
|
configuration_method: ConfigurationMethod;
|
|
engine_information?: {};
|
|
driver?: string;
|
|
sqlalchemy_uri_placeholder?: string;
|
|
};
|
|
}
|
|
| {
|
|
type:
|
|
| ActionType.Reset
|
|
| ActionType.RemoveSSHTunnelConfig
|
|
| ActionType.AddTableCatalogSheet;
|
|
}
|
|
| {
|
|
type: ActionType.RemoveTableCatalogSheet;
|
|
payload: {
|
|
indexToDelete: number;
|
|
};
|
|
}
|
|
| {
|
|
type: ActionType.ConfigMethodChange;
|
|
payload: {
|
|
database_name?: string;
|
|
engine?: string;
|
|
configuration_method: ConfigurationMethod;
|
|
};
|
|
}
|
|
| {
|
|
type: ActionType.SetSSHTunnelLoginMethod;
|
|
payload: {
|
|
login_method: AuthType;
|
|
};
|
|
};
|
|
|
|
const StyledBtns = styled.div`
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: ${({ theme }) => theme.sizeUnit * 5}px;
|
|
`;
|
|
|
|
export function dbReducer(
|
|
state: Partial<DatabaseObject> | null,
|
|
action: DBReducerActionType,
|
|
): Partial<DatabaseObject> | null {
|
|
const trimmedState = {
|
|
...state,
|
|
};
|
|
let query = {};
|
|
// eslint-disable-next-line camelcase
|
|
let query_input = '';
|
|
let parametersCatalog;
|
|
let actionPayloadJson;
|
|
const extraJson: ExtraJson = JSON.parse(trimmedState.extra || '{}');
|
|
|
|
switch (action.type) {
|
|
case ActionType.ExtraEditorChange:
|
|
// "extra" payload in state is a string
|
|
try {
|
|
// we don't want to stringify encoded strings twice
|
|
actionPayloadJson = JSON.parse(action.payload.json || '{}');
|
|
} catch {
|
|
actionPayloadJson = action.payload.json;
|
|
}
|
|
return {
|
|
...trimmedState,
|
|
extra: JSON.stringify({
|
|
...extraJson,
|
|
[action.payload.name]: actionPayloadJson,
|
|
}),
|
|
};
|
|
case ActionType.EncryptedExtraInputChange: {
|
|
// `masked_encrypted_extra` can arrive as the literal string "null" or
|
|
// malformed JSON from older payloads — defend the parse so a single
|
|
// bad value can't crash the reducer.
|
|
let parsedUnknown: unknown;
|
|
try {
|
|
parsedUnknown = JSON.parse(trimmedState.masked_encrypted_extra || '{}');
|
|
} catch (e) {
|
|
if (!(e instanceof SyntaxError)) throw e;
|
|
parsedUnknown = {};
|
|
}
|
|
const parsed: Record<string, unknown> =
|
|
parsedUnknown &&
|
|
typeof parsedUnknown === 'object' &&
|
|
!Array.isArray(parsedUnknown)
|
|
? (parsedUnknown as Record<string, unknown>)
|
|
: {};
|
|
// Generic input change: store the value as-is (including empty string).
|
|
// Use `ClearEncryptedExtraKey` if the intent is to remove the key.
|
|
return {
|
|
...trimmedState,
|
|
masked_encrypted_extra: JSON.stringify({
|
|
...parsed,
|
|
[action.payload.name]: action.payload.value,
|
|
}),
|
|
};
|
|
}
|
|
case ActionType.ClearEncryptedExtraKey: {
|
|
// Same defensive parse as EncryptedExtraInputChange — see comment above.
|
|
let parsedUnknown: unknown;
|
|
try {
|
|
parsedUnknown = JSON.parse(trimmedState.masked_encrypted_extra || '{}');
|
|
} catch (e) {
|
|
if (!(e instanceof SyntaxError)) throw e;
|
|
parsedUnknown = {};
|
|
}
|
|
const parsed: Record<string, unknown> =
|
|
parsedUnknown &&
|
|
typeof parsedUnknown === 'object' &&
|
|
!Array.isArray(parsedUnknown)
|
|
? (parsedUnknown as Record<string, unknown>)
|
|
: {};
|
|
// Explicit key removal — used by the gsheets public/private toggle to
|
|
// drop previously stored service_account_info / oauth2_client_info so
|
|
// the save-time merge in this modal doesn't carry them forward.
|
|
delete parsed[action.payload.name as string];
|
|
return {
|
|
...trimmedState,
|
|
masked_encrypted_extra: JSON.stringify(parsed),
|
|
};
|
|
}
|
|
case ActionType.ExtraInputChange:
|
|
if (
|
|
action.payload.name === 'schema_cache_timeout' ||
|
|
action.payload.name === 'table_cache_timeout'
|
|
) {
|
|
return {
|
|
...trimmedState,
|
|
extra: JSON.stringify({
|
|
...extraJson,
|
|
metadata_cache_timeout: {
|
|
...extraJson?.metadata_cache_timeout,
|
|
[action.payload.name]: Number(action.payload.value),
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
if (action.payload.name === 'schemas_allowed_for_file_upload') {
|
|
const value = action.payload.value || '';
|
|
const filteredSchemas = value
|
|
.split(',')
|
|
.map(s => s.trim())
|
|
.filter(s => s.length > 0);
|
|
|
|
return {
|
|
...trimmedState,
|
|
extra: JSON.stringify({
|
|
...extraJson,
|
|
schemas_allowed_for_file_upload: filteredSchemas,
|
|
}),
|
|
};
|
|
}
|
|
|
|
if (action.payload.name === 'http_path') {
|
|
return {
|
|
...trimmedState,
|
|
extra: JSON.stringify({
|
|
...extraJson,
|
|
engine_params: {
|
|
connect_args: {
|
|
[action.payload.name]: action.payload.value?.trim(),
|
|
},
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
if (action.payload.name === 'expand_rows') {
|
|
return {
|
|
...trimmedState,
|
|
extra: JSON.stringify({
|
|
...extraJson,
|
|
schema_options: {
|
|
...extraJson?.schema_options,
|
|
[action.payload.name]:
|
|
'checked' in action.payload
|
|
? !!action.payload.checked
|
|
: !!action.payload.value,
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
return {
|
|
...trimmedState,
|
|
extra: JSON.stringify({
|
|
...extraJson,
|
|
[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:
|
|
// catalog params will always have a catalog state for
|
|
// dbs that use a catalog, i.e., gsheets, even if the
|
|
// fields are empty strings
|
|
if (
|
|
action.payload.type?.startsWith('catalog') &&
|
|
trimmedState.catalog !== undefined
|
|
) {
|
|
// Formatting wrapping google sheets table catalog
|
|
const catalogCopy: CatalogObject[] = [...trimmedState.catalog];
|
|
const idx = action.payload.type?.split('-')[1];
|
|
const catalogToUpdate: CatalogObject =
|
|
catalogCopy[parseInt(idx, 10)] || {};
|
|
if (action.payload.value !== undefined) {
|
|
catalogToUpdate[action.payload.name as keyof CatalogObject] =
|
|
action.payload.value;
|
|
}
|
|
|
|
// insert updated catalog to existing state
|
|
catalogCopy.splice(parseInt(idx, 10), 1, catalogToUpdate);
|
|
|
|
// format catalog for state
|
|
// eslint-disable-next-line array-callback-return
|
|
parametersCatalog = catalogCopy.reduce<Record<string, string>>(
|
|
(obj, item: CatalogObject) => {
|
|
const catalog = { ...obj };
|
|
catalog[item.name as keyof CatalogObject] = item.value;
|
|
return catalog;
|
|
},
|
|
{},
|
|
);
|
|
|
|
return {
|
|
...trimmedState,
|
|
catalog: catalogCopy,
|
|
parameters: {
|
|
...trimmedState.parameters,
|
|
catalog: parametersCatalog,
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
...trimmedState,
|
|
parameters: {
|
|
...trimmedState.parameters,
|
|
[action.payload.name]: action.payload.value,
|
|
},
|
|
};
|
|
|
|
case ActionType.ParametersSSHTunnelChange:
|
|
return {
|
|
...trimmedState,
|
|
ssh_tunnel: {
|
|
...trimmedState.ssh_tunnel,
|
|
[action.payload.name]: action.payload.value,
|
|
},
|
|
};
|
|
case ActionType.SetSSHTunnelLoginMethod: {
|
|
// eslint-disable-next-line camelcase
|
|
let ssh_tunnel = {};
|
|
if (trimmedState?.ssh_tunnel) {
|
|
// remove any attributes that are considered sensitive
|
|
// eslint-disable-next-line camelcase
|
|
ssh_tunnel = pick(trimmedState.ssh_tunnel, [
|
|
'id',
|
|
'server_address',
|
|
'server_port',
|
|
'username',
|
|
]);
|
|
}
|
|
if (action.payload.login_method === AuthType.PrivateKey) {
|
|
return {
|
|
...trimmedState,
|
|
// eslint-disable-next-line camelcase
|
|
ssh_tunnel: {
|
|
private_key: trimmedState?.ssh_tunnel?.private_key,
|
|
private_key_password:
|
|
trimmedState?.ssh_tunnel?.private_key_password,
|
|
// eslint-disable-next-line camelcase
|
|
...ssh_tunnel,
|
|
},
|
|
};
|
|
}
|
|
if (action.payload.login_method === AuthType.Password) {
|
|
return {
|
|
...trimmedState,
|
|
// eslint-disable-next-line camelcase
|
|
ssh_tunnel: {
|
|
password: trimmedState?.ssh_tunnel?.password,
|
|
// eslint-disable-next-line camelcase
|
|
...ssh_tunnel,
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
...trimmedState,
|
|
};
|
|
}
|
|
case ActionType.RemoveSSHTunnelConfig:
|
|
return {
|
|
...trimmedState,
|
|
ssh_tunnel: undefined,
|
|
};
|
|
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.QueryChange:
|
|
return {
|
|
...trimmedState,
|
|
parameters: {
|
|
...trimmedState.parameters,
|
|
query: Object.fromEntries(new URLSearchParams(action.payload.value)),
|
|
},
|
|
// eslint-disable-next-line camelcase
|
|
query_input: action.payload.value,
|
|
};
|
|
case ActionType.TextChange:
|
|
return {
|
|
...trimmedState,
|
|
[action.payload.name]: action.payload.value,
|
|
};
|
|
case ActionType.Fetched:
|
|
// convert query to a string and store in query_input
|
|
query = action.payload?.parameters?.query || {};
|
|
// eslint-disable-next-line camelcase
|
|
query_input = Object.entries(query)
|
|
.map(([key, value]) => `${key}=${value}`)
|
|
.join('&');
|
|
|
|
if (
|
|
action.payload.masked_encrypted_extra &&
|
|
action.payload.configuration_method === ConfigurationMethod.DynamicForm
|
|
) {
|
|
// "extra" payload from the api is a string
|
|
const extraJsonPayload: ExtraJson = {
|
|
...JSON.parse((action.payload.extra as string) || '{}'),
|
|
};
|
|
|
|
const payloadCatalog = extraJsonPayload.engine_params?.catalog;
|
|
|
|
const engineRootCatalog = Object.entries(payloadCatalog || {}).map(
|
|
([name, value]: string[]) => ({ name, value }),
|
|
);
|
|
|
|
return {
|
|
...action.payload,
|
|
engine: action.payload.backend || trimmedState.engine,
|
|
configuration_method: action.payload.configuration_method,
|
|
catalog: engineRootCatalog,
|
|
parameters: {
|
|
...(action.payload.parameters || trimmedState.parameters),
|
|
catalog: payloadCatalog,
|
|
},
|
|
// eslint-disable-next-line camelcase
|
|
engine_information:
|
|
action.payload.engine_information ||
|
|
trimmedState.engine_information,
|
|
// eslint-disable-next-line camelcase
|
|
query_input,
|
|
};
|
|
}
|
|
return {
|
|
...action.payload,
|
|
masked_encrypted_extra: action.payload.masked_encrypted_extra || '',
|
|
engine: action.payload.backend || trimmedState.engine,
|
|
configuration_method: action.payload.configuration_method,
|
|
parameters: action.payload.parameters || trimmedState.parameters,
|
|
ssh_tunnel: action.payload.ssh_tunnel || trimmedState.ssh_tunnel,
|
|
// eslint-disable-next-line camelcase
|
|
engine_information:
|
|
action.payload.engine_information || trimmedState.engine_information,
|
|
// eslint-disable-next-line camelcase
|
|
query_input,
|
|
};
|
|
|
|
case ActionType.DbSelected:
|
|
// set initial state for blank form
|
|
return {
|
|
...action.payload,
|
|
extra: DEFAULT_EXTRA,
|
|
expose_in_sqllab: true,
|
|
};
|
|
case ActionType.ConfigMethodChange:
|
|
return {
|
|
...action.payload,
|
|
};
|
|
|
|
case ActionType.Reset:
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const DEFAULT_TAB_KEY = TABS_KEYS.BASIC;
|
|
|
|
const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
|
addDangerToast,
|
|
addSuccessToast,
|
|
onDatabaseAdd,
|
|
onHide,
|
|
show,
|
|
databaseId,
|
|
dbEngine,
|
|
}) => {
|
|
const [db, setDB] = useReducer<
|
|
Reducer<Partial<DatabaseObject> | null, DBReducerActionType>
|
|
>(dbReducer, null);
|
|
// Database fetch logic
|
|
const {
|
|
state: { loading: dbLoading, resource: dbFetched, error: dbErrors },
|
|
fetchResource,
|
|
createResource,
|
|
updateResource,
|
|
clearError,
|
|
} = useSingleViewResource<DatabaseObject>(
|
|
'database',
|
|
t('database'),
|
|
addDangerToast,
|
|
'connection',
|
|
);
|
|
|
|
const [tabKey, setTabKey] = useState<string>(DEFAULT_TAB_KEY);
|
|
const [availableDbs, getAvailableDbs] = useAvailableDatabases();
|
|
const [
|
|
validationErrors,
|
|
getValidation,
|
|
setValidationErrors,
|
|
isValidating,
|
|
hasValidated,
|
|
setHasValidated,
|
|
] = useDatabaseValidation();
|
|
const lastValidatedDbSnapshotRef = useRef<string | null>(null);
|
|
const [hasConnectedDb, setHasConnectedDb] = useState<boolean>(false);
|
|
const [showCTAbtns, setShowCTAbtns] = useState(false);
|
|
const [dbName, setDbName] = useState('');
|
|
const [editNewDb, setEditNewDb] = useState<boolean>(false);
|
|
const [isLoading, setLoading] = useState<boolean>(false);
|
|
const [testInProgress, setTestInProgress] = useState<boolean>(false);
|
|
const [passwords, setPasswords] = useState<Record<string, string>>({});
|
|
const [sshTunnelPasswords, setSSHTunnelPasswords] = useState<
|
|
Record<string, string>
|
|
>({});
|
|
const [sshTunnelPrivateKeys, setSSHTunnelPrivateKeys] = useState<
|
|
Record<string, string>
|
|
>({});
|
|
const [sshTunnelPrivateKeyPasswords, setSSHTunnelPrivateKeyPasswords] =
|
|
useState<Record<string, string>>({});
|
|
const [confirmedOverwrite, setConfirmedOverwrite] = useState<boolean>(false);
|
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
|
const [importingModal, setImportingModal] = useState<boolean>(false);
|
|
const [importingErrorMessage, setImportingErrorMessage] = useState<string>();
|
|
const [passwordFields, setPasswordFields] = useState<string[]>([]);
|
|
const [sshTunnelPasswordFields, setSSHTunnelPasswordFields] = useState<
|
|
string[]
|
|
>([]);
|
|
const [sshTunnelPrivateKeyFields, setSSHTunnelPrivateKeyFields] = useState<
|
|
string[]
|
|
>([]);
|
|
const [
|
|
sshTunnelPrivateKeyPasswordFields,
|
|
setSSHTunnelPrivateKeyPasswordFields,
|
|
] = useState<string[]>([]);
|
|
const [encryptedExtraFields, setEncryptedExtraFields] = useState<
|
|
FileEncryptedExtraFields[]
|
|
>([]);
|
|
const [encryptedExtraSecrets, setEncryptedExtraSecrets] = useState<
|
|
Record<string, Record<string, string>>
|
|
>({});
|
|
const [extraExtensionComponentState, setExtraExtensionComponentState] =
|
|
useState<object>({});
|
|
|
|
const SSHTunnelSwitchComponent =
|
|
extensionsRegistry.get('ssh_tunnel.form.switch') ?? SSHTunnelSwitch;
|
|
|
|
const [useSSHTunneling, setUseSSHTunneling] = useState<boolean | undefined>(
|
|
undefined,
|
|
);
|
|
|
|
let dbConfigExtraExtension = extensionsRegistry.get(
|
|
'databaseconnection.extraOption',
|
|
);
|
|
|
|
if (dbConfigExtraExtension) {
|
|
// add method for db modal to store data
|
|
dbConfigExtraExtension = {
|
|
...dbConfigExtraExtension,
|
|
onEdit: componentState => {
|
|
setExtraExtensionComponentState({
|
|
...extraExtensionComponentState,
|
|
...componentState,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
const conf = useCommonConf();
|
|
const dbImages = getDatabaseImages();
|
|
const connectionAlert = getConnectionAlert();
|
|
const isEditMode = !!databaseId;
|
|
const hasAlert =
|
|
connectionAlert ||
|
|
!!(
|
|
db?.engine &&
|
|
engineSpecificAlertMapping[
|
|
db.engine as keyof typeof engineSpecificAlertMapping
|
|
]
|
|
);
|
|
const useSqlAlchemyForm =
|
|
db?.configuration_method === ConfigurationMethod.SqlalchemyUri;
|
|
const useTabLayout = isEditMode || useSqlAlchemyForm;
|
|
const isDynamic = (engine: string | undefined) =>
|
|
availableDbs?.databases?.find(
|
|
(DB: DatabaseObject) => DB.backend === engine || DB.engine === engine,
|
|
)?.parameters !== undefined;
|
|
const showDBError = validationErrors || dbErrors;
|
|
const history = useHistory();
|
|
|
|
const dbModel: DatabaseForm =
|
|
// TODO: we need a centralized engine in one place
|
|
|
|
// first try to match both engine and driver
|
|
availableDbs?.databases?.find(
|
|
(available: {
|
|
engine: string | undefined;
|
|
default_driver: string | undefined;
|
|
}) =>
|
|
available.engine === (isEditMode ? db?.backend : db?.engine) &&
|
|
available.default_driver === db?.driver,
|
|
) ||
|
|
// alternatively try to match only engine
|
|
availableDbs?.databases?.find(
|
|
(available: { engine: string | undefined }) =>
|
|
available.engine === (isEditMode ? db?.backend : db?.engine),
|
|
) ||
|
|
{};
|
|
|
|
const handleClearValidationErrors = useCallback(() => {
|
|
setValidationErrors(null);
|
|
setHasValidated(false);
|
|
lastValidatedDbSnapshotRef.current = null;
|
|
clearError();
|
|
}, [setValidationErrors, setHasValidated, clearError]);
|
|
|
|
// Test Connection logic
|
|
const testConnection = () => {
|
|
handleClearValidationErrors();
|
|
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,
|
|
masked_encrypted_extra: db?.masked_encrypted_extra || '',
|
|
server_cert: db?.server_cert || undefined,
|
|
ssh_tunnel:
|
|
!isEmpty(db?.ssh_tunnel) && useSSHTunneling
|
|
? {
|
|
...db.ssh_tunnel,
|
|
server_port: Number(db.ssh_tunnel!.server_port),
|
|
}
|
|
: undefined,
|
|
};
|
|
setTestInProgress(true);
|
|
testDatabaseConnection(
|
|
connection,
|
|
(errorMsg: string) => {
|
|
setTestInProgress(false);
|
|
addDangerToast(errorMsg);
|
|
setHasValidated(false);
|
|
},
|
|
(errorMsg: string) => {
|
|
setTestInProgress(false);
|
|
addSuccessToast(errorMsg);
|
|
setHasValidated(true);
|
|
},
|
|
);
|
|
};
|
|
|
|
const getPlaceholder = (field: string) => {
|
|
if (field === 'database') {
|
|
return t('e.g. world_population');
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const removeFile = (removedFile: UploadFile) => {
|
|
setFileList(fileList.filter(file => file.uid !== removedFile.uid));
|
|
return false;
|
|
};
|
|
|
|
const onChange = useCallback(
|
|
(
|
|
type: DBReducerActionType['type'],
|
|
payload: CustomTextType | DBReducerPayloadType,
|
|
) => {
|
|
setDB({ type, payload } as DBReducerActionType);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleParametersChange = useCallback(
|
|
({ target }: { target: HTMLInputElement }) => {
|
|
onChange(ActionType.ParametersChange, {
|
|
type: target.type,
|
|
name: target.name,
|
|
checked: target.checked,
|
|
value: target.value,
|
|
});
|
|
},
|
|
[onChange],
|
|
);
|
|
|
|
const handleTextChange = useCallback(
|
|
({ target }: { target: HTMLInputElement }) => {
|
|
onChange(ActionType.TextChange, {
|
|
name: target.name,
|
|
value: target.value,
|
|
});
|
|
},
|
|
[onChange],
|
|
);
|
|
|
|
const handleChangeWithValidation = useCallback(
|
|
(
|
|
actionType: ActionType,
|
|
payload: CustomTextType | DBReducerPayloadType,
|
|
) => {
|
|
onChange(actionType, payload);
|
|
handleClearValidationErrors();
|
|
},
|
|
[onChange, handleClearValidationErrors],
|
|
);
|
|
|
|
const getBlurValidation = useCallback(async () => {
|
|
const currentDbSnapshot = JSON.stringify(db);
|
|
if (currentDbSnapshot === lastValidatedDbSnapshotRef.current) {
|
|
return [];
|
|
}
|
|
const result = await getValidation(db);
|
|
// Only cache after a request that produced a usable response. ``null``
|
|
// signals an unexpected/network failure, in which case we leave the
|
|
// snapshot untouched so the next blur retries.
|
|
if (result !== null) {
|
|
lastValidatedDbSnapshotRef.current = currentDbSnapshot;
|
|
}
|
|
return result;
|
|
}, [db, getValidation]);
|
|
|
|
const onClose = () => {
|
|
setDB({ type: ActionType.Reset });
|
|
setHasConnectedDb(false);
|
|
handleClearValidationErrors(); // reset validation errors on close
|
|
clearError();
|
|
setEditNewDb(false);
|
|
setFileList([]);
|
|
setImportingModal(false);
|
|
setImportingErrorMessage('');
|
|
setPasswordFields([]);
|
|
setSSHTunnelPasswordFields([]);
|
|
setSSHTunnelPrivateKeyFields([]);
|
|
setSSHTunnelPrivateKeyPasswordFields([]);
|
|
setPasswords({});
|
|
setSSHTunnelPasswords({});
|
|
setSSHTunnelPrivateKeys({});
|
|
setSSHTunnelPrivateKeyPasswords({});
|
|
setEncryptedExtraFields([]);
|
|
setEncryptedExtraSecrets({});
|
|
setConfirmedOverwrite(false);
|
|
setUseSSHTunneling(undefined);
|
|
onHide();
|
|
};
|
|
|
|
const redirectURL = (url: string) => {
|
|
history.push(url);
|
|
};
|
|
|
|
// Database import logic
|
|
const {
|
|
state: {
|
|
alreadyExists,
|
|
passwordsNeeded,
|
|
sshPasswordNeeded,
|
|
sshPrivateKeyNeeded,
|
|
sshPrivateKeyPasswordNeeded,
|
|
encryptedExtraFieldsNeeded,
|
|
loading: importLoading,
|
|
failed: importErrored,
|
|
},
|
|
importResource,
|
|
} = useImportResource('database', t('database'), msg => {
|
|
setImportingErrorMessage(msg);
|
|
});
|
|
|
|
const onSave = async () => {
|
|
let dbConfigExtraExtensionOnSaveError;
|
|
setLoading(true);
|
|
setHasValidated(false);
|
|
dbConfigExtraExtension
|
|
?.onSave(extraExtensionComponentState, db)
|
|
.then(({ error }: { error: any }) => {
|
|
if (error) {
|
|
dbConfigExtraExtensionOnSaveError = error;
|
|
addDangerToast(error);
|
|
}
|
|
});
|
|
|
|
if (dbConfigExtraExtensionOnSaveError) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
// Clone DB object
|
|
const dbToUpdate = { ...db };
|
|
|
|
if (dbToUpdate.configuration_method === ConfigurationMethod.DynamicForm) {
|
|
// Validate DB before saving
|
|
if (dbToUpdate?.parameters?.catalog) {
|
|
// need to stringify gsheets catalog to allow it to be serialized
|
|
dbToUpdate.extra = JSON.stringify({
|
|
...JSON.parse(dbToUpdate.extra || '{}'),
|
|
engine_params: {
|
|
catalog: dbToUpdate.parameters.catalog,
|
|
},
|
|
});
|
|
}
|
|
|
|
const errors = await getValidation(dbToUpdate, true);
|
|
// ``getValidation`` returns ``[]`` on success, a field-keyed object
|
|
// for blocking errors (e.g. the duplicate ``database_name`` check),
|
|
// and ``null`` for stale or unexpected responses. During save we
|
|
// cannot proceed without a usable result, so treat ``null`` as
|
|
// blocking too — only ``[]`` is a clean pass.
|
|
const hasReturnedErrors =
|
|
errors === null ||
|
|
(Array.isArray(errors) ? errors.length > 0 : !isEmpty(errors));
|
|
if (!isEmpty(validationErrors) || hasReturnedErrors) {
|
|
addDangerToast(
|
|
t('Connection failed, please check your connection settings.'),
|
|
);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// eslint-disable-next-line camelcase
|
|
const parameters_schema = isEditMode
|
|
? dbToUpdate.parameters_schema?.properties
|
|
: dbModel?.parameters.properties;
|
|
const additionalEncryptedExtra = JSON.parse(
|
|
dbToUpdate.masked_encrypted_extra || '{}',
|
|
);
|
|
// eslint-disable-next-line camelcase
|
|
const paramConfigArray = Object.keys(parameters_schema || {});
|
|
|
|
paramConfigArray.forEach(paramConfig => {
|
|
/*
|
|
* Parameters that are annotated with the `x-encrypted-extra` properties should be
|
|
* moved to `masked_encrypted_extra`, so that they are stored encrypted in the
|
|
* backend when the database is created or edited.
|
|
*/
|
|
if (
|
|
// eslint-disable-next-line camelcase
|
|
parameters_schema[paramConfig]['x-encrypted-extra'] &&
|
|
dbToUpdate.parameters?.[paramConfig as keyof DatabaseParameters]
|
|
) {
|
|
if (
|
|
typeof dbToUpdate.parameters?.[
|
|
paramConfig as keyof DatabaseParameters
|
|
] === 'object'
|
|
) {
|
|
// add new encrypted extra to masked_encrypted_extra object
|
|
additionalEncryptedExtra[paramConfig] =
|
|
dbToUpdate.parameters?.[paramConfig as keyof DatabaseParameters];
|
|
// The backend expects `masked_encrypted_extra` as a string for historical
|
|
// reasons.
|
|
dbToUpdate.parameters[
|
|
paramConfig as OnlyKeyWithType<DatabaseParameters, string>
|
|
] = JSON.stringify(
|
|
dbToUpdate.parameters[paramConfig as keyof DatabaseParameters],
|
|
);
|
|
} else {
|
|
additionalEncryptedExtra[paramConfig] = JSON.parse(
|
|
dbToUpdate.parameters?.[
|
|
paramConfig as OnlyKeyWithType<DatabaseParameters, string>
|
|
] || '{}',
|
|
);
|
|
}
|
|
}
|
|
});
|
|
// cast the new encrypted extra object into a string
|
|
dbToUpdate.masked_encrypted_extra = JSON.stringify(
|
|
additionalEncryptedExtra,
|
|
);
|
|
// this needs to be added by default to gsheets
|
|
if (dbToUpdate.engine === Engines.GSheet) {
|
|
dbToUpdate.impersonate_user = true;
|
|
}
|
|
}
|
|
|
|
if (dbToUpdate?.parameters?.catalog) {
|
|
// need to stringify gsheets catalog to allow it to be serialized
|
|
dbToUpdate.extra = JSON.stringify({
|
|
...JSON.parse(dbToUpdate.extra || '{}'),
|
|
engine_params: {
|
|
catalog: dbToUpdate.parameters.catalog,
|
|
},
|
|
});
|
|
}
|
|
|
|
// strictly checking for false as an indication that the toggle got unchecked
|
|
if (useSSHTunneling === false) {
|
|
// remove ssh tunnel
|
|
dbToUpdate.ssh_tunnel = null;
|
|
}
|
|
|
|
if (db?.id) {
|
|
const result = await updateResource(
|
|
db.id as number,
|
|
dbToUpdate as DatabaseObject,
|
|
dbToUpdate.configuration_method === ConfigurationMethod.DynamicForm, // onShow toast on SQLA Forms
|
|
);
|
|
if (result) {
|
|
if (onDatabaseAdd) onDatabaseAdd();
|
|
dbConfigExtraExtension
|
|
?.onSave(extraExtensionComponentState, db)
|
|
.then(({ error }: { error: any }) => {
|
|
if (error) {
|
|
dbConfigExtraExtensionOnSaveError = error;
|
|
addDangerToast(error);
|
|
}
|
|
});
|
|
if (dbConfigExtraExtensionOnSaveError) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
if (!editNewDb) {
|
|
onClose();
|
|
addSuccessToast(t('Database settings updated'));
|
|
}
|
|
}
|
|
} else if (db) {
|
|
// Create
|
|
const dbId = await createResource(
|
|
dbToUpdate as DatabaseObject,
|
|
dbToUpdate.configuration_method === ConfigurationMethod.DynamicForm, // onShow toast on SQLA Forms
|
|
);
|
|
if (dbId) {
|
|
setHasConnectedDb(true);
|
|
if (onDatabaseAdd) onDatabaseAdd();
|
|
dbConfigExtraExtension
|
|
?.onSave(extraExtensionComponentState, db)
|
|
.then(({ error }: { error: any }) => {
|
|
if (error) {
|
|
dbConfigExtraExtensionOnSaveError = error;
|
|
addDangerToast(error);
|
|
}
|
|
});
|
|
if (dbConfigExtraExtensionOnSaveError) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (useTabLayout) {
|
|
// tab layout only has one step
|
|
// so it should close immediately on save
|
|
onClose();
|
|
addSuccessToast(t('Database connected'));
|
|
}
|
|
}
|
|
} else {
|
|
// Import - doesn't use db state
|
|
setImportingModal(true);
|
|
|
|
if (!(fileList[0].originFileObj instanceof File)) {
|
|
return;
|
|
}
|
|
|
|
const dbId = await importResource(
|
|
fileList[0].originFileObj,
|
|
passwords,
|
|
sshTunnelPasswords,
|
|
sshTunnelPrivateKeys,
|
|
sshTunnelPrivateKeyPasswords,
|
|
encryptedExtraSecrets,
|
|
confirmedOverwrite,
|
|
);
|
|
if (dbId) {
|
|
if (onDatabaseAdd) onDatabaseAdd();
|
|
onClose();
|
|
addSuccessToast(t('Database connected'));
|
|
}
|
|
}
|
|
|
|
setShowCTAbtns(true);
|
|
setEditNewDb(false);
|
|
setLoading(false);
|
|
};
|
|
|
|
// 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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
// eslint-disable-next-line camelcase
|
|
const setDatabaseModel = (database_name: string) => {
|
|
// eslint-disable-next-line camelcase
|
|
if (database_name === 'Other') {
|
|
// Allow users to connect to DB via legacy SQLA form
|
|
setDB({
|
|
type: ActionType.DbSelected,
|
|
payload: {
|
|
// eslint-disable-next-line camelcase
|
|
database_name,
|
|
configuration_method: ConfigurationMethod.SqlalchemyUri,
|
|
engine: undefined,
|
|
// eslint-disable-next-line camelcase
|
|
engine_information: {
|
|
supports_file_upload: true,
|
|
},
|
|
},
|
|
});
|
|
} else {
|
|
const selectedDbModel = availableDbs?.databases.filter(
|
|
// eslint-disable-next-line camelcase
|
|
(db: DatabaseObject) => db.name === database_name,
|
|
)[0];
|
|
if (!selectedDbModel) return;
|
|
const {
|
|
engine,
|
|
parameters,
|
|
// eslint-disable-next-line camelcase
|
|
engine_information,
|
|
// eslint-disable-next-line camelcase
|
|
default_driver,
|
|
// eslint-disable-next-line camelcase
|
|
sqlalchemy_uri_placeholder,
|
|
} = selectedDbModel;
|
|
const isDynamic = parameters !== undefined;
|
|
setDB({
|
|
type: ActionType.DbSelected,
|
|
payload: {
|
|
// eslint-disable-next-line camelcase
|
|
database_name,
|
|
engine,
|
|
configuration_method: isDynamic
|
|
? ConfigurationMethod.DynamicForm
|
|
: ConfigurationMethod.SqlalchemyUri,
|
|
// eslint-disable-next-line camelcase
|
|
engine_information,
|
|
// eslint-disable-next-line camelcase
|
|
driver: default_driver,
|
|
// eslint-disable-next-line camelcase
|
|
sqlalchemy_uri_placeholder,
|
|
},
|
|
});
|
|
|
|
if (engine === Engines.GSheet) {
|
|
// only create a catalog if the DB is Google Sheets
|
|
setDB({ type: ActionType.AddTableCatalogSheet });
|
|
}
|
|
}
|
|
};
|
|
|
|
const renderAvailableSelector = () => (
|
|
<div className="available">
|
|
<h4 className="available-label">
|
|
{t('Or choose from a list of other databases we support:')}
|
|
</h4>
|
|
<FormLabel className="control-label">
|
|
{t('Supported databases')}
|
|
</FormLabel>
|
|
<Select
|
|
className="available-select"
|
|
onChange={setDatabaseModel}
|
|
placeholder={t('Choose a database...')}
|
|
options={[
|
|
...(availableDbs?.databases || []).map(
|
|
(database: DatabaseForm, index: number) => ({
|
|
value: database.name,
|
|
label: database.name,
|
|
key: `database-${index}`,
|
|
}),
|
|
),
|
|
{ value: 'Other', label: t('Other'), key: 'Other' },
|
|
]}
|
|
showSearch
|
|
sortComparator={(a, b) => {
|
|
// Always put "Other" at the end
|
|
if (a.value === 'Other') return 1;
|
|
if (b.value === 'Other') return -1;
|
|
// For all other options, sort alphabetically
|
|
return String(a.label).localeCompare(String(b.label));
|
|
}}
|
|
getPopupContainer={triggerNode =>
|
|
triggerNode.parentElement || document.body
|
|
}
|
|
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
|
/>
|
|
<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 ? (
|
|
<>
|
|
{t(
|
|
'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}
|
|
</>
|
|
) : (
|
|
<>
|
|
{t(
|
|
'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"
|
|
>
|
|
{t('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]}
|
|
key={`${database.name}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
const handleBackButtonOnFinish = () => {
|
|
if (dbFetched) {
|
|
fetchResource(dbFetched.id as number);
|
|
}
|
|
setShowCTAbtns(false);
|
|
setEditNewDb(true);
|
|
};
|
|
|
|
const handleBackButtonOnConnect = () => {
|
|
handleClearValidationErrors();
|
|
if (editNewDb) setHasConnectedDb(false);
|
|
if (importingModal) setImportingModal(false);
|
|
if (importErrored) {
|
|
setImportingModal(false);
|
|
setImportingErrorMessage('');
|
|
setPasswordFields([]);
|
|
setSSHTunnelPasswordFields([]);
|
|
setSSHTunnelPrivateKeyFields([]);
|
|
setSSHTunnelPrivateKeyPasswordFields([]);
|
|
setEncryptedExtraFields([]);
|
|
setPasswords({});
|
|
setSSHTunnelPasswords({});
|
|
setSSHTunnelPrivateKeys({});
|
|
setSSHTunnelPrivateKeyPasswords({});
|
|
setEncryptedExtraSecrets({});
|
|
}
|
|
setDB({ type: ActionType.Reset });
|
|
setFileList([]);
|
|
};
|
|
|
|
const handleDisableOnImport = () => {
|
|
// Check if any encrypted extra field is missing a secret
|
|
const hasEmptyEncryptedExtraSecrets = encryptedExtraFields.some(
|
|
({ fileName, fields }) =>
|
|
fields.some(field => !encryptedExtraSecrets[fileName]?.[field.path]),
|
|
);
|
|
|
|
if (
|
|
importLoading ||
|
|
(alreadyExists.length && !confirmedOverwrite) ||
|
|
(passwordsNeeded.length && JSON.stringify(passwords) === '{}') ||
|
|
(sshPasswordNeeded.length &&
|
|
JSON.stringify(sshTunnelPasswords) === '{}') ||
|
|
(sshPrivateKeyNeeded.length &&
|
|
JSON.stringify(sshTunnelPrivateKeys) === '{}') ||
|
|
(sshPrivateKeyPasswordNeeded.length &&
|
|
JSON.stringify(sshTunnelPrivateKeyPasswords) === '{}') ||
|
|
(encryptedExtraFields.length && hasEmptyEncryptedExtraSecrets)
|
|
)
|
|
return true;
|
|
return false;
|
|
};
|
|
|
|
const renderModalFooter = () => {
|
|
if (db) {
|
|
// if db show back + connect
|
|
if (!hasConnectedDb || editNewDb) {
|
|
return (
|
|
<>
|
|
<StyledFooterButton
|
|
key="back"
|
|
onClick={handleBackButtonOnConnect}
|
|
buttonStyle="secondary"
|
|
>
|
|
{t('Back')}
|
|
</StyledFooterButton>
|
|
<StyledFooterButton
|
|
data-test="btn-submit-connection"
|
|
key="submit"
|
|
buttonStyle="primary"
|
|
onClick={onSave}
|
|
loading={isLoading}
|
|
disabled={
|
|
!!(
|
|
!hasValidated ||
|
|
isValidating ||
|
|
(validationErrors && Object.keys(validationErrors).length > 0)
|
|
)
|
|
}
|
|
>
|
|
{t('Connect')}
|
|
</StyledFooterButton>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<StyledFooterButton key="back" onClick={handleBackButtonOnFinish}>
|
|
{t('Back')}
|
|
</StyledFooterButton>
|
|
<StyledFooterButton
|
|
key="submit"
|
|
buttonStyle="primary"
|
|
onClick={onSave}
|
|
data-test="modal-confirm-button"
|
|
loading={isLoading}
|
|
>
|
|
{t('Finish')}
|
|
</StyledFooterButton>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Import doesn't use db state, so footer will not render in the if statement above
|
|
if (importingModal) {
|
|
return (
|
|
<>
|
|
<StyledFooterButton key="back" onClick={handleBackButtonOnConnect}>
|
|
{t('Back')}
|
|
</StyledFooterButton>
|
|
<StyledFooterButton
|
|
key="submit"
|
|
buttonStyle="primary"
|
|
onClick={onSave}
|
|
disabled={handleDisableOnImport()}
|
|
loading={isLoading}
|
|
>
|
|
{t('Connect')}
|
|
</StyledFooterButton>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return <></>;
|
|
};
|
|
|
|
const renderEditModalFooter = (db: Partial<DatabaseObject> | null) => (
|
|
<>
|
|
<StyledFooterButton key="close" onClick={onClose} buttonStyle="secondary">
|
|
{t('Close')}
|
|
</StyledFooterButton>
|
|
<StyledFooterButton
|
|
key="submit"
|
|
buttonStyle="primary"
|
|
onClick={onSave}
|
|
disabled={db?.is_managed_externally}
|
|
loading={isLoading}
|
|
tooltip={
|
|
db?.is_managed_externally
|
|
? t(
|
|
"This database is managed externally, and can't be edited in Superset",
|
|
)
|
|
: ''
|
|
}
|
|
>
|
|
{t('Finish')}
|
|
</StyledFooterButton>
|
|
</>
|
|
);
|
|
|
|
const firstUpdate = useRef(true); // Captures first render
|
|
// Only runs when importing files don't need user input
|
|
useEffect(() => {
|
|
// Will not run on first render
|
|
if (firstUpdate.current) {
|
|
firstUpdate.current = false;
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!importLoading &&
|
|
!alreadyExists.length &&
|
|
!passwordsNeeded.length &&
|
|
!sshPasswordNeeded.length &&
|
|
!sshPrivateKeyNeeded.length &&
|
|
!sshPrivateKeyPasswordNeeded.length &&
|
|
!encryptedExtraFieldsNeeded.length &&
|
|
!isLoading && // This prevents a double toast for non-related imports
|
|
!importErrored // This prevents a success toast on error
|
|
) {
|
|
onClose();
|
|
addSuccessToast(t('Database connected'));
|
|
}
|
|
}, [
|
|
alreadyExists,
|
|
passwordsNeeded,
|
|
importLoading,
|
|
importErrored,
|
|
sshPasswordNeeded,
|
|
sshPrivateKeyNeeded,
|
|
sshPrivateKeyPasswordNeeded,
|
|
encryptedExtraFieldsNeeded,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (show) {
|
|
setTabKey(DEFAULT_TAB_KEY);
|
|
setLoading(true);
|
|
getAvailableDbs();
|
|
}
|
|
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);
|
|
}
|
|
|
|
if (availableDbs && dbEngine) {
|
|
// set model if passed into props
|
|
setDatabaseModel(dbEngine);
|
|
}
|
|
}, [availableDbs]);
|
|
|
|
// This forces the modal to scroll until the importing filename is in view
|
|
useEffect(() => {
|
|
if (importingModal) {
|
|
document
|
|
?.getElementsByClassName('ant-upload-list-item-name')[0]
|
|
?.scrollIntoView();
|
|
}
|
|
}, [importingModal]);
|
|
|
|
useEffect(() => {
|
|
setPasswordFields([...passwordsNeeded]);
|
|
}, [passwordsNeeded]);
|
|
|
|
useEffect(() => {
|
|
setSSHTunnelPasswordFields([...sshPasswordNeeded]);
|
|
}, [sshPasswordNeeded]);
|
|
|
|
useEffect(() => {
|
|
setSSHTunnelPrivateKeyFields([...sshPrivateKeyNeeded]);
|
|
}, [sshPrivateKeyNeeded]);
|
|
|
|
useEffect(() => {
|
|
setSSHTunnelPrivateKeyPasswordFields([...sshPrivateKeyPasswordNeeded]);
|
|
}, [sshPrivateKeyPasswordNeeded]);
|
|
|
|
useEffect(() => {
|
|
setEncryptedExtraFields([...encryptedExtraFieldsNeeded]);
|
|
}, [encryptedExtraFieldsNeeded]);
|
|
|
|
useEffect(() => {
|
|
if (db?.parameters?.ssh !== undefined) {
|
|
setUseSSHTunneling(db.parameters.ssh);
|
|
}
|
|
}, [db?.parameters?.ssh]);
|
|
|
|
const onDbImport = async (info: UploadChangeParam) => {
|
|
setImportingErrorMessage('');
|
|
setPasswordFields([]);
|
|
setSSHTunnelPasswordFields([]);
|
|
setSSHTunnelPrivateKeyFields([]);
|
|
setSSHTunnelPrivateKeyPasswordFields([]);
|
|
setEncryptedExtraFields([]);
|
|
setPasswords({});
|
|
setSSHTunnelPasswords({});
|
|
setSSHTunnelPrivateKeys({});
|
|
setSSHTunnelPrivateKeyPasswords({});
|
|
setEncryptedExtraSecrets({});
|
|
setImportingModal(true);
|
|
setFileList([
|
|
{
|
|
...info.file,
|
|
status: 'done',
|
|
},
|
|
]);
|
|
|
|
if (!(info.file.originFileObj instanceof File)) return;
|
|
const dbId = await importResource(
|
|
info.file.originFileObj,
|
|
passwords,
|
|
sshTunnelPasswords,
|
|
sshTunnelPrivateKeys,
|
|
sshTunnelPrivateKeyPasswords,
|
|
encryptedExtraSecrets,
|
|
confirmedOverwrite,
|
|
);
|
|
if (dbId) onDatabaseAdd?.();
|
|
};
|
|
|
|
const passwordNeededField = () => {
|
|
if (
|
|
!passwordFields.length &&
|
|
!sshTunnelPasswordFields.length &&
|
|
!sshTunnelPrivateKeyFields.length &&
|
|
!sshTunnelPrivateKeyPasswordFields.length
|
|
)
|
|
return null;
|
|
|
|
const files = [
|
|
...new Set([
|
|
...passwordFields,
|
|
...sshTunnelPasswordFields,
|
|
...sshTunnelPrivateKeyFields,
|
|
...sshTunnelPrivateKeyPasswordFields,
|
|
]),
|
|
];
|
|
|
|
return files.map(database => (
|
|
<>
|
|
<StyledAlertMargin>
|
|
<Alert
|
|
closable={false}
|
|
css={(theme: SupersetTheme) => antDAlertStyles(theme)}
|
|
type="info"
|
|
showIcon
|
|
message="Database passwords"
|
|
description={t(
|
|
`The passwords for the databases below are needed in order to import them.`,
|
|
)}
|
|
/>
|
|
</StyledAlertMargin>
|
|
{passwordFields?.indexOf(database) >= 0 && (
|
|
<ValidatedInput
|
|
id="password_needed"
|
|
name="password_needed"
|
|
required
|
|
value={passwords[database]}
|
|
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
|
setPasswords({ ...passwords, [database]: event.target.value })
|
|
}
|
|
isValidating={isValidating}
|
|
validationMethods={{ onBlur: () => {} }}
|
|
errorMessage={validationErrors?.password_needed}
|
|
label={t('%s PASSWORD', database.slice(10))}
|
|
css={formScrollableStyles}
|
|
/>
|
|
)}
|
|
{sshTunnelPasswordFields?.indexOf(database) >= 0 && (
|
|
<ValidatedInput
|
|
isValidating={isValidating}
|
|
id="ssh_tunnel_password_needed"
|
|
name="ssh_tunnel_password_needed"
|
|
required
|
|
value={sshTunnelPasswords[database]}
|
|
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
|
setSSHTunnelPasswords({
|
|
...sshTunnelPasswords,
|
|
[database]: event.target.value,
|
|
})
|
|
}
|
|
validationMethods={{ onBlur: () => {} }}
|
|
errorMessage={validationErrors?.ssh_tunnel_password_needed}
|
|
label={t('%s SSH TUNNEL PASSWORD', database.slice(10))}
|
|
css={formScrollableStyles}
|
|
/>
|
|
)}
|
|
{sshTunnelPrivateKeyFields?.indexOf(database) >= 0 && (
|
|
<ValidatedInput
|
|
id="ssh_tunnel_private_key_needed"
|
|
name="ssh_tunnel_private_key_needed"
|
|
isValidating={isValidating}
|
|
required
|
|
value={sshTunnelPrivateKeys[database]}
|
|
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
|
setSSHTunnelPrivateKeys({
|
|
...sshTunnelPrivateKeys,
|
|
[database]: event.target.value,
|
|
})
|
|
}
|
|
validationMethods={{ onBlur: () => {} }}
|
|
errorMessage={validationErrors?.ssh_tunnel_private_key_needed}
|
|
label={t('%s SSH TUNNEL PRIVATE KEY', database.slice(10))}
|
|
css={formScrollableStyles}
|
|
/>
|
|
)}
|
|
{sshTunnelPrivateKeyPasswordFields?.indexOf(database) >= 0 && (
|
|
<ValidatedInput
|
|
id="ssh_tunnel_private_key_password_needed"
|
|
name="ssh_tunnel_private_key_password_needed"
|
|
isValidating={isValidating}
|
|
required
|
|
value={sshTunnelPrivateKeyPasswords[database]}
|
|
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
|
setSSHTunnelPrivateKeyPasswords({
|
|
...sshTunnelPrivateKeyPasswords,
|
|
[database]: event.target.value,
|
|
})
|
|
}
|
|
validationMethods={{ onBlur: () => {} }}
|
|
errorMessage={
|
|
validationErrors?.ssh_tunnel_private_key_password_needed
|
|
}
|
|
label={t('%s SSH TUNNEL PRIVATE KEY PASSWORD', database.slice(10))}
|
|
css={formScrollableStyles}
|
|
/>
|
|
)}
|
|
</>
|
|
));
|
|
};
|
|
|
|
const encryptedExtraNeededField = () => {
|
|
if (!encryptedExtraFields.length) return null;
|
|
|
|
return encryptedExtraFields.map(({ fileName, fields }) => (
|
|
<div key={fileName}>
|
|
<StyledAlertMargin>
|
|
<Alert
|
|
closable={false}
|
|
css={(theme: SupersetTheme) => antDAlertStyles(theme)}
|
|
type="info"
|
|
showIcon
|
|
message={t('Encrypted extra fields')}
|
|
description={t(
|
|
`The following fields contain sensitive information that was masked during export. Please provide the values to import this database.`,
|
|
)}
|
|
/>
|
|
</StyledAlertMargin>
|
|
{fields.map(field => (
|
|
<ValidatedInput
|
|
key={`${fileName}-${field.path}`}
|
|
id={`encrypted_extra_${field.path}`}
|
|
name={`encrypted_extra_${field.path}`}
|
|
required
|
|
visibilityToggle
|
|
value={encryptedExtraSecrets[fileName]?.[field.path] || ''}
|
|
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
|
setEncryptedExtraSecrets({
|
|
...encryptedExtraSecrets,
|
|
[fileName]: {
|
|
...encryptedExtraSecrets[fileName],
|
|
[field.path]: event.target.value,
|
|
},
|
|
})
|
|
}
|
|
isValidating={isValidating}
|
|
validationMethods={{ onBlur: () => {} }}
|
|
label={t('%s %s', fileName.slice(10), field.label)}
|
|
css={formScrollableStyles}
|
|
/>
|
|
))}
|
|
</div>
|
|
));
|
|
};
|
|
|
|
const importingErrorAlert = () => {
|
|
if (!importingErrorMessage) return null;
|
|
|
|
return (
|
|
<StyledAlertMargin>
|
|
<ErrorAlert message={importingErrorMessage} />
|
|
</StyledAlertMargin>
|
|
);
|
|
};
|
|
|
|
const confirmOverwrite = (event: ChangeEvent<HTMLInputElement>) => {
|
|
const targetValue = (event.currentTarget?.value as string) ?? '';
|
|
setConfirmedOverwrite(targetValue.toUpperCase() === t('OVERWRITE'));
|
|
};
|
|
|
|
const confirmOverwriteField = () => {
|
|
if (!alreadyExists.length) return null;
|
|
|
|
return (
|
|
<>
|
|
<StyledAlertMargin>
|
|
<Alert
|
|
closable={false}
|
|
css={(theme: SupersetTheme) => antdWarningAlertStyles(theme)}
|
|
type="warning"
|
|
showIcon
|
|
message=""
|
|
description={t(
|
|
'You are importing one or more databases that already exist. Overwriting might cause you to lose some of your work. Are you sure you want to overwrite?',
|
|
)}
|
|
/>
|
|
</StyledAlertMargin>
|
|
<ValidatedInput
|
|
id="confirm_overwrite"
|
|
name="confirm_overwrite"
|
|
isValidating={isValidating}
|
|
required
|
|
validationMethods={{ onBlur: () => {} }}
|
|
errorMessage={validationErrors?.confirm_overwrite}
|
|
label={t('Type "%s" to confirm', t('OVERWRITE'))}
|
|
onChange={confirmOverwrite}
|
|
css={formScrollableStyles}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const tabChange = (key: string) => setTabKey(key);
|
|
|
|
const renderStepTwoAlert = () => {
|
|
const { hostname } = window.location;
|
|
let ipAlert = connectionAlert?.REGIONAL_IPS?.default || '';
|
|
const regionalIPs = connectionAlert?.REGIONAL_IPS || {};
|
|
Object.entries(regionalIPs).forEach(([ipRegion, ipRange]) => {
|
|
const regex = new RegExp(ipRegion);
|
|
if (hostname.match(regex)) ipAlert = ipRange;
|
|
});
|
|
return (
|
|
db?.engine && (
|
|
<StyledAlertMargin>
|
|
<Alert
|
|
closable={false}
|
|
css={(theme: SupersetTheme) => antDAlertStyles(theme)}
|
|
type="info"
|
|
showIcon
|
|
message={
|
|
engineSpecificAlertMapping[
|
|
db.engine as keyof typeof engineSpecificAlertMapping
|
|
]?.message || connectionAlert?.DEFAULT?.message
|
|
}
|
|
description={
|
|
engineSpecificAlertMapping[
|
|
db.engine as keyof typeof engineSpecificAlertMapping
|
|
]?.description || connectionAlert?.DEFAULT?.description + ipAlert
|
|
}
|
|
/>
|
|
</StyledAlertMargin>
|
|
)
|
|
);
|
|
};
|
|
|
|
// eslint-disable-next-line consistent-return
|
|
const errorAlert = () => {
|
|
let alertErrors: string[] = [];
|
|
if (!isEmpty(dbErrors)) {
|
|
alertErrors =
|
|
typeof dbErrors === 'object'
|
|
? Object.values(dbErrors)
|
|
: typeof dbErrors === 'string'
|
|
? [dbErrors]
|
|
: [];
|
|
} else if (
|
|
!isEmpty(validationErrors) &&
|
|
validationErrors?.error_type === 'GENERIC_DB_ENGINE_ERROR'
|
|
) {
|
|
alertErrors = [
|
|
validationErrors?.description || validationErrors?.message,
|
|
];
|
|
}
|
|
if (alertErrors.length) {
|
|
return (
|
|
<ErrorAlertContainer>
|
|
<ErrorMessageWithStackTrace
|
|
title={t('Database Creation Error')}
|
|
subtitle={t('We are unable to connect to your database.')}
|
|
descriptionDetails={
|
|
alertErrors?.[0] || validationErrors?.description
|
|
}
|
|
copyText={validationErrors?.description}
|
|
/>
|
|
</ErrorAlertContainer>
|
|
);
|
|
}
|
|
return <></>;
|
|
};
|
|
|
|
const fetchAndSetDB = () => {
|
|
setLoading(true);
|
|
fetchResource(dbFetched?.id as number).then(r => {
|
|
setItem(LocalStorageKeys.Database, r);
|
|
});
|
|
};
|
|
|
|
const renderSSHTunnelForm = () => (
|
|
<SSHTunnelForm
|
|
db={db as DatabaseObject}
|
|
onSSHTunnelParametersChange={({ target }) => {
|
|
onChange(ActionType.ParametersSSHTunnelChange, {
|
|
type: target.type,
|
|
name: target.name,
|
|
value: target.value,
|
|
});
|
|
}}
|
|
setSSHTunnelLoginMethod={(method: AuthType) =>
|
|
setDB({
|
|
type: ActionType.SetSSHTunnelLoginMethod,
|
|
payload: { login_method: method },
|
|
})
|
|
}
|
|
isValidating={isValidating}
|
|
validationErrors={validationErrors}
|
|
getValidation={getBlurValidation}
|
|
/>
|
|
);
|
|
|
|
const renderCTABtns = () => (
|
|
<StyledBtns>
|
|
<Button
|
|
buttonStyle="secondary"
|
|
onClick={() => {
|
|
setLoading(true);
|
|
fetchAndSetDB();
|
|
redirectURL('/dataset/add/');
|
|
}}
|
|
>
|
|
{t('Create dataset')}
|
|
</Button>
|
|
<Button
|
|
buttonStyle="secondary"
|
|
onClick={() => {
|
|
setLoading(true);
|
|
fetchAndSetDB();
|
|
// redirectURL() delegates to history.push; React Router's basename
|
|
// already prefixes the application root, so pass a relative path.
|
|
redirectURL('/sqllab?db=true');
|
|
}}
|
|
>
|
|
{t('Query data in SQL Lab')}
|
|
</Button>
|
|
</StyledBtns>
|
|
);
|
|
|
|
const renderDatabaseConnectionForm = () => (
|
|
<>
|
|
<DatabaseConnectionForm
|
|
isValidating={isValidating}
|
|
isEditMode={isEditMode}
|
|
db={db as DatabaseObject}
|
|
sslForced={false}
|
|
dbModel={dbModel}
|
|
onAddTableCatalog={() => {
|
|
setDB({ type: ActionType.AddTableCatalogSheet });
|
|
}}
|
|
onQueryChange={({ target }: { target: HTMLInputElement }) =>
|
|
handleChangeWithValidation(ActionType.QueryChange, {
|
|
name: target.name,
|
|
value: target.value,
|
|
})
|
|
}
|
|
onExtraInputChange={({ target }: { target: HTMLInputElement }) =>
|
|
handleChangeWithValidation(ActionType.ExtraInputChange, {
|
|
name: target.name,
|
|
value: target.value,
|
|
})
|
|
}
|
|
onEncryptedExtraInputChange={({
|
|
target,
|
|
}: {
|
|
target: HTMLInputElement;
|
|
}) =>
|
|
handleChangeWithValidation(ActionType.EncryptedExtraInputChange, {
|
|
name: target.name,
|
|
value: target.value,
|
|
})
|
|
}
|
|
onClearEncryptedExtraKey={(name: string) =>
|
|
handleChangeWithValidation(ActionType.ClearEncryptedExtraKey, {
|
|
name,
|
|
})
|
|
}
|
|
onRemoveTableCatalog={(idx: number) => {
|
|
setDB({
|
|
type: ActionType.RemoveTableCatalogSheet,
|
|
payload: { indexToDelete: idx },
|
|
});
|
|
}}
|
|
onParametersChange={handleParametersChange}
|
|
onChange={handleTextChange}
|
|
getValidation={getBlurValidation}
|
|
validationErrors={validationErrors}
|
|
getPlaceholder={getPlaceholder}
|
|
clearValidationErrors={handleClearValidationErrors}
|
|
/>
|
|
{useSSHTunneling && (
|
|
<SSHTunnelContainer>{renderSSHTunnelForm()}</SSHTunnelContainer>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
const renderFinishState = () => {
|
|
if (!editNewDb) {
|
|
return (
|
|
<ExtraOptions
|
|
extraExtension={dbConfigExtraExtension}
|
|
db={db as DatabaseObject}
|
|
onInputChange={(
|
|
e: CheckboxChangeEvent | React.ChangeEvent<HTMLInputElement>,
|
|
) => {
|
|
const { target } = e;
|
|
handleChangeWithValidation(ActionType.InputChange, {
|
|
type: target.type,
|
|
name: target.name,
|
|
checked: 'checked' in target ? target.checked : false,
|
|
value: target.value,
|
|
});
|
|
}}
|
|
onTextChange={({ target }: { target: HTMLTextAreaElement }) =>
|
|
handleChangeWithValidation(ActionType.TextChange, {
|
|
name: target.name,
|
|
value: target.value,
|
|
})
|
|
}
|
|
onEditorChange={(payload: { name: string; json: any }) =>
|
|
handleChangeWithValidation(ActionType.EditorChange, payload)
|
|
}
|
|
onExtraInputChange={(
|
|
e: CheckboxChangeEvent | React.ChangeEvent<HTMLInputElement>,
|
|
) => {
|
|
const { target } = e;
|
|
handleChangeWithValidation(ActionType.ExtraInputChange, {
|
|
type: target.type,
|
|
name: target.name,
|
|
checked: 'checked' in target ? target.checked : false,
|
|
value: target.value,
|
|
});
|
|
}}
|
|
onExtraEditorChange={(payload: { name: string; json: any }) =>
|
|
handleChangeWithValidation(ActionType.ExtraEditorChange, payload)
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
return renderDatabaseConnectionForm();
|
|
};
|
|
|
|
if (
|
|
fileList.length > 0 &&
|
|
(alreadyExists.length ||
|
|
passwordFields.length ||
|
|
sshTunnelPasswordFields.length ||
|
|
sshTunnelPrivateKeyFields.length ||
|
|
sshTunnelPrivateKeyPasswordFields.length ||
|
|
encryptedExtraFields.length)
|
|
) {
|
|
return (
|
|
<Modal
|
|
centered
|
|
css={(theme: SupersetTheme) => [
|
|
antDModalNoPaddingStyles,
|
|
antDModalStyles(theme),
|
|
formHelperStyles(theme),
|
|
formStyles(theme),
|
|
]}
|
|
footer={renderModalFooter()}
|
|
maskClosable={false}
|
|
name="database"
|
|
onHide={onClose}
|
|
onHandledPrimaryAction={onSave}
|
|
primaryButtonName={t('Connect')}
|
|
show={show}
|
|
title={
|
|
<ModalTitleWithIcon
|
|
title={t('Connect a database')}
|
|
icon={<Icons.InsertRowAboveOutlined />}
|
|
/>
|
|
}
|
|
width="500px"
|
|
>
|
|
<ModalHeader
|
|
db={db}
|
|
dbName={dbName}
|
|
dbModel={dbModel}
|
|
fileList={fileList}
|
|
hasConnectedDb={hasConnectedDb}
|
|
isEditMode={isEditMode}
|
|
isLoading={isLoading}
|
|
useSqlAlchemyForm={useSqlAlchemyForm}
|
|
/>
|
|
{confirmOverwriteField()}
|
|
{importingErrorAlert()}
|
|
{passwordNeededField()}
|
|
{encryptedExtraNeededField()}
|
|
</Modal>
|
|
);
|
|
}
|
|
const modalFooter = isEditMode
|
|
? renderEditModalFooter(db)
|
|
: renderModalFooter();
|
|
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={
|
|
<ModalTitleWithIcon
|
|
isEditMode={isEditMode}
|
|
title={isEditMode ? t('Edit database') : t('Connect a database')}
|
|
icon={
|
|
isEditMode ? (
|
|
<Icons.EditOutlined iconSize="l" />
|
|
) : (
|
|
<Icons.InsertRowAboveOutlined iconSize="l" />
|
|
)
|
|
}
|
|
/>
|
|
}
|
|
footer={modalFooter}
|
|
maskClosable={false}
|
|
>
|
|
<StyledStickyHeader>
|
|
<TabHeader>
|
|
<ModalHeader
|
|
isLoading={isLoading}
|
|
isEditMode={isEditMode}
|
|
useSqlAlchemyForm={useSqlAlchemyForm}
|
|
hasConnectedDb={hasConnectedDb}
|
|
db={db}
|
|
dbName={dbName}
|
|
dbModel={dbModel}
|
|
/>
|
|
</TabHeader>
|
|
</StyledStickyHeader>
|
|
<TabsStyled
|
|
defaultActiveKey={DEFAULT_TAB_KEY}
|
|
activeKey={tabKey}
|
|
onTabClick={tabChange}
|
|
animated={{ inkBar: true, tabPane: true }}
|
|
items={[
|
|
{
|
|
key: TABS_KEYS.BASIC,
|
|
label: <span>{t('Basic')}</span>,
|
|
children: (
|
|
<>
|
|
{useSqlAlchemyForm ? (
|
|
<StyledAlignment>
|
|
<SqlAlchemyForm
|
|
db={db as DatabaseObject}
|
|
onInputChange={({
|
|
target,
|
|
}: {
|
|
target: HTMLInputElement;
|
|
}) => {
|
|
setHasValidated(false);
|
|
onChange(ActionType.InputChange, {
|
|
type: target.type,
|
|
name: target.name,
|
|
checked: target.checked,
|
|
value: target.value,
|
|
});
|
|
}}
|
|
conf={conf}
|
|
testConnection={testConnection}
|
|
testInProgress={testInProgress}
|
|
>
|
|
<SSHTunnelSwitchComponent
|
|
dbModel={dbModel}
|
|
db={db as DatabaseObject}
|
|
changeMethods={{
|
|
onParametersChange: handleParametersChange,
|
|
}}
|
|
clearValidationErrors={handleClearValidationErrors}
|
|
/>
|
|
{useSSHTunneling && renderSSHTunnelForm()}
|
|
</SqlAlchemyForm>
|
|
{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:
|
|
ConfigurationMethod.DynamicForm,
|
|
engine: db?.engine,
|
|
},
|
|
})
|
|
}
|
|
css={theme => alchemyButtonLinkStyles(theme)}
|
|
>
|
|
{t(
|
|
'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.',
|
|
)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</StyledAlignment>
|
|
) : (
|
|
renderDatabaseConnectionForm()
|
|
)}
|
|
{!isEditMode && (
|
|
<StyledAlertMargin>
|
|
<Alert
|
|
closable={false}
|
|
css={(theme: SupersetTheme) => antDAlertStyles(theme)}
|
|
message={t('Additional fields may be required')}
|
|
showIcon
|
|
description={
|
|
<>
|
|
{t(
|
|
'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"
|
|
>
|
|
{t('here')}
|
|
</a>
|
|
.
|
|
</>
|
|
}
|
|
type="info"
|
|
/>
|
|
</StyledAlertMargin>
|
|
)}
|
|
{showDBError && errorAlert()}
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
key: TABS_KEYS.ADVANCED,
|
|
label: <span>{t('Advanced')}</span>,
|
|
children: (
|
|
<ExtraOptions
|
|
extraExtension={dbConfigExtraExtension}
|
|
db={db as DatabaseObject}
|
|
onInputChange={(e: CheckboxChangeEvent) => {
|
|
const { target } = e;
|
|
handleChangeWithValidation(ActionType.InputChange, {
|
|
type: target.type,
|
|
name: target.name,
|
|
checked: target.checked,
|
|
value: target.value,
|
|
});
|
|
}}
|
|
onTextChange={({ target }: { target: HTMLTextAreaElement }) =>
|
|
handleChangeWithValidation(ActionType.TextChange, {
|
|
name: target.name,
|
|
value: target.value,
|
|
})
|
|
}
|
|
onEditorChange={(payload: { name: string; json: any }) =>
|
|
handleChangeWithValidation(ActionType.EditorChange, payload)
|
|
}
|
|
onExtraInputChange={(
|
|
e: React.ChangeEvent<HTMLInputElement> | CheckboxChangeEvent,
|
|
) => {
|
|
const { target } = e;
|
|
handleChangeWithValidation(ActionType.ExtraInputChange, {
|
|
type: target.type,
|
|
name: target.name,
|
|
checked: target.checked,
|
|
value: target.value,
|
|
});
|
|
}}
|
|
onExtraEditorChange={(payload: { name: string; json: any }) =>
|
|
handleChangeWithValidation(
|
|
ActionType.ExtraEditorChange,
|
|
payload,
|
|
)
|
|
}
|
|
/>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</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={
|
|
<ModalTitleWithIcon
|
|
title={t('Connect a database')}
|
|
icon={<Icons.InsertRowAboveOutlined />}
|
|
/>
|
|
}
|
|
footer={renderModalFooter()}
|
|
maskClosable={false}
|
|
>
|
|
{!isLoading && hasConnectedDb ? (
|
|
<>
|
|
<ModalHeader
|
|
isLoading={isLoading}
|
|
isEditMode={isEditMode}
|
|
useSqlAlchemyForm={useSqlAlchemyForm}
|
|
hasConnectedDb={hasConnectedDb}
|
|
db={db}
|
|
dbName={dbName}
|
|
dbModel={dbModel}
|
|
editNewDb={editNewDb}
|
|
/>
|
|
{showCTAbtns && renderCTABtns()}
|
|
{renderFinishState()}
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* Dynamic Form Step 1 */}
|
|
{!isLoading &&
|
|
(!db ? (
|
|
<SelectDatabaseStyles>
|
|
<ModalHeader
|
|
isLoading={isLoading}
|
|
isEditMode={isEditMode}
|
|
useSqlAlchemyForm={useSqlAlchemyForm}
|
|
hasConnectedDb={hasConnectedDb}
|
|
db={db}
|
|
dbName={dbName}
|
|
dbModel={dbModel}
|
|
/>
|
|
{renderPreferredSelector()}
|
|
{renderAvailableSelector()}
|
|
<StyledUploadWrapper>
|
|
<Upload
|
|
name="databaseFile"
|
|
id="databaseFile"
|
|
data-test="database-file-input"
|
|
accept=".yaml,.json,.yml,.zip"
|
|
customRequest={() => {}}
|
|
onChange={onDbImport}
|
|
onRemove={removeFile}
|
|
>
|
|
<Button
|
|
data-test="import-database-btn"
|
|
buttonStyle="link"
|
|
css={importDbButtonLinkStyles}
|
|
>
|
|
{t('Import database from file')}
|
|
</Button>
|
|
</Upload>
|
|
</StyledUploadWrapper>
|
|
{importingErrorAlert()}
|
|
</SelectDatabaseStyles>
|
|
) : (
|
|
<>
|
|
<ModalHeader
|
|
isLoading={isLoading}
|
|
isEditMode={isEditMode}
|
|
useSqlAlchemyForm={useSqlAlchemyForm}
|
|
hasConnectedDb={hasConnectedDb}
|
|
db={db}
|
|
dbName={dbName}
|
|
dbModel={dbModel}
|
|
/>
|
|
{hasAlert && renderStepTwoAlert()}
|
|
{renderDatabaseConnectionForm()}
|
|
<div css={(theme: SupersetTheme) => infoTooltip(theme)}>
|
|
{dbModel.engine !== Engines.GSheet && (
|
|
<>
|
|
<Button
|
|
data-test="sqla-connect-btn"
|
|
buttonStyle="link"
|
|
onClick={() => {
|
|
handleClearValidationErrors();
|
|
setDB({
|
|
type: ActionType.ConfigMethodChange,
|
|
payload: {
|
|
engine: db.engine,
|
|
configuration_method:
|
|
ConfigurationMethod.SqlalchemyUri,
|
|
database_name: db.database_name,
|
|
},
|
|
});
|
|
}}
|
|
css={buttonLinkStyles}
|
|
>
|
|
{t(
|
|
'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.',
|
|
)}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
{/* Step 2 */}
|
|
{showDBError && errorAlert()}
|
|
</>
|
|
))}
|
|
</>
|
|
)}
|
|
{isLoading && <Loading />}
|
|
</Modal>
|
|
);
|
|
};
|
|
|
|
export default withToasts(DatabaseModal);
|