@@ -142,19 +154,23 @@ const ModalHeader = ({
);
+ const importDbHeader = (
+
+
+ STEP 2 OF 2
+ Enter the required {dbModel.name} credentials
+ {fileCheck ? fileList[0].name : ''}
+
+
+ );
+
+ if (fileCheck) return importDbHeader;
if (isLoading) return <>>;
- if (isEditMode) {
- return isEditHeader;
- }
- if (useSqlAlchemyForm) {
- return useSqlAlchemyFormHeader;
- }
- if (hasConnectedDb && !editNewDb) {
- return hasConnectedDbHeader;
- }
- if (db || editNewDb) {
- return hasDbHeader;
- }
+ if (isEditMode) return isEditHeader;
+ if (useSqlAlchemyForm) return useSqlAlchemyFormHeader;
+ if (hasConnectedDb && !editNewDb) return hasConnectedDbHeader;
+ if (db || editNewDb) return hasDbHeader;
+
return noDbHeader;
};
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx
index 92f54650f048..07fcd29a7b79 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx
@@ -1028,7 +1028,24 @@ describe('DatabaseModal', () => {
*/
});
});
+
+ describe('Import database flow', () => {
+ it('imports a file', () => {
+ const importDbButton = screen.getByTestId('import-database-btn');
+ expect(importDbButton).toBeVisible();
+
+ const testFile = new File([new ArrayBuffer(1)], 'model_export.zip');
+
+ userEvent.click(importDbButton);
+ userEvent.upload(importDbButton, testFile);
+
+ expect(importDbButton.files[0]).toStrictEqual(testFile);
+ expect(importDbButton.files.item(0)).toStrictEqual(testFile);
+ expect(importDbButton.files).toHaveLength(1);
+ });
+ });
});
+
describe('DatabaseModal w/ Deeplinking Engine', () => {
const renderAndWait = async () => {
const mounted = act(async () => {
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx
index ce684bb25c0f..583b540579e9 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx
@@ -17,62 +17,70 @@
* under the License.
*/
import {
+ t,
+ SupersetTheme,
FeatureFlag,
isFeatureEnabled,
- SupersetTheme,
- t,
} from '@superset-ui/core';
import React, {
FunctionComponent,
- Reducer,
useEffect,
- useReducer,
+ useRef,
useState,
+ useReducer,
+ Reducer,
} from 'react';
+import { UploadChangeParam, UploadFile } from 'antd/lib/upload/interface';
import Tabs from 'src/components/Tabs';
-import { AntdSelect } from 'src/components';
+import { AntdSelect, Upload } from 'src/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/components/MessageToasts/withToasts';
+import ValidatedInput from 'src/components/Form/LabeledErrorBoundInput';
import {
- getConnectionAlert,
- getDatabaseImages,
testDatabaseConnection,
+ useSingleViewResource,
useAvailableDatabases,
useDatabaseValidation,
- useSingleViewResource,
+ getDatabaseImages,
+ getConnectionAlert,
+ useImportResource,
} from 'src/views/CRUD/hooks';
import { useCommonConf } from 'src/views/CRUD/data/database/state';
import {
- CatalogObject,
- CONFIGURATION_METHOD,
- DatabaseForm,
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 {
- alchemyButtonLinkStyles,
- antDAlertStyles,
antDErrorAlertStyles,
+ antDAlertStyles,
+ antdWarningAlertStyles,
+ StyledAlertMargin,
antDModalNoPaddingStyles,
antDModalStyles,
antDTabsStyles,
buttonLinkStyles,
+ importDbButtonLinkStyles,
+ alchemyButtonLinkStyles,
+ TabHeader,
formHelperStyles,
formStyles,
- infoTooltip,
- SelectDatabaseStyles,
- StyledAlertMargin,
StyledAlignment,
+ SelectDatabaseStyles,
+ infoTooltip,
StyledFooterButton,
StyledStickyHeader,
- TabHeader,
+ formScrollableStyles,
+ StyledUploadWrapper,
} from './styles';
import ModalHeader, { DOCUMENTATION_LINK } from './ModalHeader';
@@ -131,7 +139,6 @@ const errorAlertMapping = {
),
},
};
-
interface DatabaseModalProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
@@ -403,10 +410,12 @@ function dbReducer(
return {
...action.payload,
};
+
case ActionType.configMethodChange:
return {
...action.payload,
};
+
case ActionType.reset:
default:
return null;
@@ -437,6 +446,19 @@ const DatabaseModal: FunctionComponent
= ({
const [db, setDB] = useReducer<
Reducer | null, DBReducerActionType>
>(dbReducer, null);
+ // Database fetch logic
+ const {
+ state: { loading: dbLoading, resource: dbFetched, error: dbErrors },
+ fetchResource,
+ createResource,
+ updateResource,
+ clearError,
+ } = useSingleViewResource(
+ 'database',
+ t('database'),
+ addDangerToast,
+ );
+
const [tabKey, setTabKey] = useState(DEFAULT_TAB_KEY);
const [availableDbs, getAvailableDbs] = useAvailableDatabases();
const [validationErrors, getValidation, setValidationErrors] =
@@ -446,6 +468,11 @@ const DatabaseModal: FunctionComponent = ({
const [editNewDb, setEditNewDb] = useState(false);
const [isLoading, setLoading] = useState(false);
const [testInProgress, setTestInProgress] = useState(false);
+ const [passwords, setPasswords] = useState>({});
+ const [confirmedOverwrite, setConfirmedOverwrite] = useState(false);
+ const [fileList, setFileList] = useState([]);
+ const [importingModal, setImportingModal] = useState(false);
+
const conf = useCommonConf();
const dbImages = getDatabaseImages();
const connectionAlert = getConnectionAlert();
@@ -458,18 +485,6 @@ const DatabaseModal: FunctionComponent = ({
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(
- 'database',
- t('database'),
- addDangerToast,
- );
const isDynamic = (engine: string | undefined) =>
availableDbs?.databases?.find(
(DB: DatabaseObject) => DB.backend === engine || DB.engine === engine,
@@ -514,14 +529,43 @@ const DatabaseModal: FunctionComponent = ({
);
};
+ const removeFile = (removedFile: UploadFile) => {
+ setFileList(fileList.filter(file => file.uid !== removedFile.uid));
+ return false;
+ };
+
const onClose = () => {
setDB({ type: ActionType.reset });
setHasConnectedDb(false);
setValidationErrors(null); // reset validation errors on close
clearError();
setEditNewDb(false);
+ setFileList([]);
+ setImportingModal(false);
+ setPasswords({});
+ setConfirmedOverwrite(false);
+ if (onDatabaseAdd) onDatabaseAdd();
onHide();
};
+
+ // Database import logic
+ const {
+ state: {
+ alreadyExists,
+ passwordsNeeded,
+ loading: importLoading,
+ failed: importErrored,
+ },
+ importResource,
+ } = useImportResource('database', t('database'), msg => {
+ addDangerToast(msg);
+ onClose();
+ });
+
+ const onChange = (type: any, payload: any) => {
+ setDB({ type, payload } as DBReducerActionType);
+ };
+
const onSave = async () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, ...update } = db || {};
@@ -597,9 +641,7 @@ const DatabaseModal: FunctionComponent = ({
dbToUpdate.configuration_method === CONFIGURATION_METHOD.DYNAMIC_FORM, // onShow toast on SQLA Forms
);
if (result) {
- if (onDatabaseAdd) {
- onDatabaseAdd();
- }
+ if (onDatabaseAdd) onDatabaseAdd();
if (!editNewDb) {
onClose();
addSuccessToast(t('Database settings updated'));
@@ -614,9 +656,7 @@ const DatabaseModal: FunctionComponent = ({
);
if (dbId) {
setHasConnectedDb(true);
- if (onDatabaseAdd) {
- onDatabaseAdd();
- }
+ if (onDatabaseAdd) onDatabaseAdd();
if (useTabLayout) {
// tab layout only has one step
// so it should close immediately on save
@@ -625,14 +665,29 @@ const DatabaseModal: FunctionComponent = ({
}
}
}
+
+ // Import - doesn't use db state
+ if (!db) {
+ setLoading(true);
+ setImportingModal(true);
+
+ if (!(fileList[0].originFileObj instanceof File)) return;
+ const dbId = await importResource(
+ fileList[0].originFileObj,
+ passwords,
+ confirmedOverwrite,
+ );
+
+ if (dbId) {
+ onClose();
+ addSuccessToast(t('Database connected'));
+ }
+ }
+
setEditNewDb(false);
setLoading(false);
};
- const onChange = (type: any, payload: any) => {
- setDB({ type, payload } as DBReducerActionType);
- };
-
// Initialize
const fetchDB = () => {
if (isEditMode && databaseId) {
@@ -774,10 +829,20 @@ const DatabaseModal: FunctionComponent = ({
};
const handleBackButtonOnConnect = () => {
- if (editNewDb) {
- setHasConnectedDb(false);
- }
+ if (editNewDb) setHasConnectedDb(false);
+ if (importingModal) setImportingModal(false);
setDB({ type: ActionType.reset });
+ setFileList([]);
+ };
+
+ const handleDisableOnImport = () => {
+ if (
+ importLoading ||
+ (alreadyExists.length && !confirmedOverwrite) ||
+ (passwordsNeeded.length && JSON.stringify(passwords) === '{}')
+ )
+ return true;
+ return false;
};
const renderModalFooter = () => {
@@ -816,6 +881,26 @@ const DatabaseModal: FunctionComponent = ({
>
);
}
+
+ // Import doesn't use db state, so footer will not render in the if statement above
+ if (importingModal) {
+ return (
+ <>
+
+ {t('Back')}
+
+
+ {t('Connect')}
+
+ >
+ );
+ }
+
return [];
};
@@ -841,6 +926,28 @@ const DatabaseModal: FunctionComponent = ({
>
);
+
+ 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 &&
+ !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]);
+
useEffect(() => {
if (show) {
setTabKey(DEFAULT_TAB_KEY);
@@ -875,19 +982,111 @@ const DatabaseModal: FunctionComponent = ({
}
}, [availableDbs]);
- const tabChange = (key: string) => {
- setTabKey(key);
+ // 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]);
+
+ const onDbImport = async (info: UploadChangeParam) => {
+ setImportingModal(true);
+ setFileList([
+ {
+ ...info.file,
+ status: 'done',
+ },
+ ]);
+
+ if (!(info.file.originFileObj instanceof File)) return;
+ await importResource(
+ info.file.originFileObj,
+ passwords,
+ confirmedOverwrite,
+ );
+ };
+
+ const passwordNeededField = () => {
+ if (!passwordsNeeded.length) return null;
+
+ return passwordsNeeded.map(database => (
+ <>
+
+ antDAlertStyles(theme)}
+ type="info"
+ showIcon
+ message="Database passwords"
+ description={t(
+ `The passwords for the databases below are needed in order to import them. Please note that the "Secure Extra" and "Certificate" sections of the database configuration are not present in explore files and should be added manually after the import if they are needed.`,
+ )}
+ />
+
+ ) =>
+ setPasswords({ ...passwords, [database]: event.target.value })
+ }
+ validationMethods={{ onBlur: () => {} }}
+ errorMessage={validationErrors?.password_needed}
+ label={t(`${database.slice(10)} PASSWORD`)}
+ css={formScrollableStyles}
+ />
+ >
+ ));
+ };
+
+ const confirmOverwrite = (event: React.ChangeEvent) => {
+ const targetValue = (event.currentTarget?.value as string) ?? '';
+ setConfirmedOverwrite(targetValue.toUpperCase() === t('OVERWRITE'));
};
+ const confirmOverwriteField = () => {
+ if (!alreadyExists.length) return null;
+
+ return (
+ <>
+
+ 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?',
+ )}
+ />
+
+ {} }}
+ errorMessage={validationErrors?.confirm_overwrite}
+ label={t(`TYPE "OVERWRITE" TO CONFIRM`)}
+ 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;
- }
+ if (hostname.match(regex)) ipAlert = ipRange;
});
return (
db?.engine && (
@@ -1028,6 +1227,41 @@ const DatabaseModal: FunctionComponent = ({
);
};
+ if (fileList.length > 0 && (alreadyExists.length || passwordsNeeded.length)) {
+ return (
+ [
+ antDModalNoPaddingStyles,
+ antDModalStyles(theme),
+ formHelperStyles(theme),
+ formStyles(theme),
+ ]}
+ name="database"
+ onHandledPrimaryAction={onSave}
+ onHide={onClose}
+ primaryButtonName={t('Connect')}
+ width="500px"
+ centered
+ show={show}
+ title={{t('Connect a database')}
}
+ footer={renderModalFooter()}
+ >
+
+ {passwordNeededField()}
+ {confirmOverwriteField()}
+
+ );
+ }
+
return useTabLayout ? (
[
@@ -1267,6 +1501,26 @@ const DatabaseModal: FunctionComponent = ({
/>
{renderPreferredSelector()}
{renderAvailableSelector()}
+
+ {}}
+ onChange={onDbImport}
+ onRemove={removeFile}
+ >
+
+
+
) : (
<>
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts
index c0e65b97774c..39302168b2f0 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts
@@ -218,6 +218,29 @@ export const antDErrorAlertStyles = (theme: SupersetTheme) => css`
}
`;
+export const antdWarningAlertStyles = (theme: SupersetTheme) => css`
+ border: 1px solid ${theme.colors.warning.light1};
+ padding: ${theme.gridUnit * 4}px;
+ margin: ${theme.gridUnit * 4}px 0;
+ color: ${theme.colors.warning.dark2};
+
+ .ant-alert-message {
+ margin: 0;
+ }
+
+ .ant-alert-description {
+ font-size: ${theme.typography.sizes.s + 1}px;
+ line-height: ${theme.gridUnit * 4}px;
+
+ .ant-alert-icon {
+ margin-right: ${theme.gridUnit * 2.5}px;
+ font-size: ${theme.typography.sizes.l + 1}px;
+ position: relative;
+ top: ${theme.gridUnit / 4}px;
+ }
+ }
+`;
+
export const formHelperStyles = (theme: SupersetTheme) => css`
.required {
margin-left: ${theme.gridUnit / 2}px;
@@ -399,6 +422,13 @@ export const buttonLinkStyles = (theme: SupersetTheme) => css`
padding-right: ${theme.gridUnit * 2}px;
`;
+export const importDbButtonLinkStyles = (theme: SupersetTheme) => css`
+ font-size: ${theme.gridUnit * 3.5}px;
+ font-weight: ${theme.typography.weights.normal};
+ text-transform: initial;
+ padding-right: ${theme.gridUnit * 2}px;
+`;
+
export const alchemyButtonLinkStyles = (theme: SupersetTheme) => css`
font-weight: ${theme.typography.weights.normal};
text-transform: initial;
@@ -583,3 +613,13 @@ export const StyledCatalogTable = styled.div`
width: 95%;
}
`;
+
+export const StyledUploadWrapper = styled.div`
+ .ant-progress-inner {
+ display: none;
+ }
+
+ .ant-upload-list-item-card-actions {
+ display: none;
+ }
+`;
diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts
index 97df78ecb24f..8164e4b368f2 100644
--- a/superset-frontend/src/views/CRUD/hooks.ts
+++ b/superset-frontend/src/views/CRUD/hooks.ts
@@ -383,6 +383,7 @@ interface ImportResourceState {
loading: boolean;
passwordsNeeded: string[];
alreadyExists: string[];
+ failed: boolean;
}
export function useImportResource(
@@ -394,6 +395,7 @@ export function useImportResource(
loading: false,
passwordsNeeded: [],
alreadyExists: [],
+ failed: false,
});
function updateState(update: Partial) {
@@ -409,6 +411,7 @@ export function useImportResource(
// Set loading state
updateState({
loading: true,
+ failed: false,
});
const formData = new FormData();
@@ -432,9 +435,19 @@ export function useImportResource(
body: formData,
headers: { Accept: 'application/json' },
})
- .then(() => true)
+ .then(() => {
+ updateState({
+ passwordsNeeded: [],
+ alreadyExists: [],
+ failed: false,
+ });
+ return true;
+ })
.catch(response =>
getClientErrorObject(response).then(error => {
+ updateState({
+ failed: true,
+ });
if (!error.errors) {
handleErrorMsg(
t(