diff --git a/superset-frontend/src/components/ImportModal/ImportModal.test.tsx b/superset-frontend/src/components/ImportModal/ImportModal.test.tsx
new file mode 100644
index 0000000000000..1e922b4d12873
--- /dev/null
+++ b/superset-frontend/src/components/ImportModal/ImportModal.test.tsx
@@ -0,0 +1,110 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React from 'react';
+import thunk from 'redux-thunk';
+import configureStore from 'redux-mock-store';
+import { styledMount as mount } from 'spec/helpers/theming';
+import { ReactWrapper } from 'enzyme';
+
+import ImportModelsModal, { StyledIcon } from 'src/components/ImportModal';
+import Modal from 'src/common/components/Modal';
+
+const mockStore = configureStore([thunk]);
+const store = mockStore({});
+
+const requiredProps = {
+ resourceName: 'model',
+ resourceLabel: 'model',
+ icon: ,
+ passwordsNeededMessage: 'Passwords are needed',
+ addDangerToast: () => {},
+ addSuccessToast: () => {},
+ onModelImport: () => {},
+ show: true,
+ onHide: () => {},
+};
+
+describe('ImportModelsModal', () => {
+ let wrapper: ReactWrapper;
+
+ beforeEach(() => {
+ wrapper = mount(, {
+ context: { store },
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders', () => {
+ expect(wrapper.find(ImportModelsModal)).toExist();
+ });
+
+ it('renders a Modal', () => {
+ expect(wrapper.find(Modal)).toExist();
+ });
+
+ it('renders "Import model" header', () => {
+ expect(wrapper.find('h4').text()).toEqual('Import model');
+ });
+
+ it('renders a label and a file input field', () => {
+ expect(wrapper.find('input[type="file"]')).toExist();
+ expect(wrapper.find('label')).toExist();
+ });
+
+ it('should attach the label to the input field', () => {
+ const id = 'modelFile';
+ expect(wrapper.find('label').prop('htmlFor')).toBe(id);
+ expect(wrapper.find('input').prop('id')).toBe(id);
+ });
+
+ it('should render the close, import and cancel buttons', () => {
+ expect(wrapper.find('button')).toHaveLength(3);
+ });
+
+ it('should render the import button initially disabled', () => {
+ expect(wrapper.find('button[children="Import"]').prop('disabled')).toBe(
+ true,
+ );
+ });
+
+ it('should render the import button enabled when a file is selected', () => {
+ const file = new File([new ArrayBuffer(1)], 'model_export.zip');
+ wrapper.find('input').simulate('change', { target: { files: [file] } });
+
+ expect(wrapper.find('button[children="Import"]').prop('disabled')).toBe(
+ false,
+ );
+ });
+
+ it('should render password fields when needed for import', () => {
+ const wrapperWithPasswords = mount(
+ ,
+ {
+ context: { store },
+ },
+ );
+ expect(wrapperWithPasswords.find('input[type="password"]')).toExist();
+ });
+});
diff --git a/superset-frontend/src/components/ImportModal/index.tsx b/superset-frontend/src/components/ImportModal/index.tsx
new file mode 100644
index 0000000000000..1c26623f10c78
--- /dev/null
+++ b/superset-frontend/src/components/ImportModal/index.tsx
@@ -0,0 +1,255 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React, { FunctionComponent, useEffect, useRef, useState } from 'react';
+import { styled, t } from '@superset-ui/core';
+
+import Icon from 'src//components/Icon';
+import Modal from 'src/common/components/Modal';
+import { useImportResource } from 'src/views/CRUD/hooks';
+
+export const StyledIcon = styled(Icon)`
+ margin: auto ${({ theme }) => theme.gridUnit * 2}px auto 0;
+`;
+
+const StyledInputContainer = styled.div`
+ margin-bottom: ${({ theme }) => theme.gridUnit * 2}px;
+
+ &.extra-container {
+ padding-top: 8px;
+ }
+
+ .helper {
+ display: block;
+ padding: ${({ theme }) => theme.gridUnit}px 0;
+ color: ${({ theme }) => theme.colors.grayscale.base};
+ font-size: ${({ theme }) => theme.typography.sizes.s - 1}px;
+ text-align: left;
+
+ .required {
+ margin-left: ${({ theme }) => theme.gridUnit / 2}px;
+ color: ${({ theme }) => theme.colors.error.base};
+ }
+ }
+
+ .input-container {
+ display: flex;
+ align-items: center;
+
+ label {
+ display: flex;
+ margin-right: ${({ theme }) => theme.gridUnit * 2}px;
+ }
+
+ i {
+ margin: 0 ${({ theme }) => theme.gridUnit}px;
+ }
+ }
+
+ input,
+ textarea {
+ flex: 1 1 auto;
+ }
+
+ textarea {
+ height: 160px;
+ resize: none;
+ }
+
+ input::placeholder,
+ textarea::placeholder {
+ color: ${({ theme }) => theme.colors.grayscale.light1};
+ }
+
+ textarea,
+ input[type='text'],
+ input[type='number'] {
+ padding: ${({ theme }) => theme.gridUnit * 1.5}px
+ ${({ theme }) => theme.gridUnit * 2}px;
+ border-style: none;
+ border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
+ border-radius: ${({ theme }) => theme.gridUnit}px;
+
+ &[name='name'] {
+ flex: 0 1 auto;
+ width: 40%;
+ }
+
+ &[name='sqlalchemy_uri'] {
+ margin-right: ${({ theme }) => theme.gridUnit * 3}px;
+ }
+ }
+`;
+
+export interface ImportModelsModalProps {
+ resourceName: string;
+ resourceLabel: string;
+ icon: React.ReactNode;
+ passwordsNeededMessage: string;
+ addDangerToast: (msg: string) => void;
+ addSuccessToast: (msg: string) => void;
+ onModelImport: () => void;
+ show: boolean;
+ onHide: () => void;
+ passwordFields?: string[];
+ setPasswordFields?: (passwordFields: string[]) => void;
+}
+
+const ImportModelsModal: FunctionComponent = ({
+ resourceName,
+ resourceLabel,
+ icon,
+ passwordsNeededMessage,
+ addDangerToast,
+ addSuccessToast,
+ onModelImport,
+ show,
+ onHide,
+ passwordFields = [],
+ setPasswordFields = () => {},
+}) => {
+ const [uploadFile, setUploadFile] = useState(null);
+ const [isHidden, setIsHidden] = useState(true);
+ const [passwords, setPasswords] = useState>({});
+ const fileInputRef = useRef(null);
+
+ const clearModal = () => {
+ setUploadFile(null);
+ setPasswordFields([]);
+ setPasswords({});
+ if (fileInputRef && fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ };
+
+ const handleErrorMsg = (msg: string) => {
+ clearModal();
+ addDangerToast(msg);
+ };
+
+ const {
+ state: { passwordsNeeded },
+ importResource,
+ } = useImportResource(resourceName, resourceLabel, handleErrorMsg);
+
+ useEffect(() => {
+ setPasswordFields(passwordsNeeded);
+ }, [passwordsNeeded, setPasswordFields]);
+
+ // Functions
+ const hide = () => {
+ setIsHidden(true);
+ onHide();
+ };
+
+ const onUpload = () => {
+ if (uploadFile === null) {
+ return;
+ }
+
+ importResource(uploadFile, passwords).then(result => {
+ if (result) {
+ addSuccessToast(t('The import was successful'));
+ clearModal();
+ onModelImport();
+ }
+ });
+ };
+
+ const changeFile = (event: React.ChangeEvent) => {
+ const { files } = event.target as HTMLInputElement;
+ setUploadFile((files && files[0]) || null);
+ };
+
+ const renderPasswordFields = () => {
+ if (passwordFields.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+ Database passwords
+
+ {passwordsNeededMessage}
+
+ {passwordFields.map(fileName => (
+
+
+ {fileName}
+ *
+
+
+ setPasswords({ ...passwords, [fileName]: event.target.value })
+ }
+ />
+
+ ))}
+ >
+ );
+ };
+
+ // Show/hide
+ if (isHidden && show) {
+ setIsHidden(false);
+ }
+
+ return (
+
+ {icon}
+ {t('Import %s', resourceLabel)}
+
+ }
+ >
+
+
+
+
+
+
+ {renderPasswordFields()}
+
+ );
+};
+
+export default ImportModelsModal;