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;