diff --git a/superset-frontend/src/dashboard/components/ImportModal/ImportModal.test.tsx b/superset-frontend/src/dashboard/components/ImportModal/ImportModal.test.tsx new file mode 100644 index 0000000000000..ac46fd65af2ce --- /dev/null +++ b/superset-frontend/src/dashboard/components/ImportModal/ImportModal.test.tsx @@ -0,0 +1,106 @@ +/** + * 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 ImportDashboardModal from 'src/dashboard/components/ImportModal'; +import Modal from 'src/common/components/Modal'; + +const mockStore = configureStore([thunk]); +const store = mockStore({}); + +const requiredProps = { + addDangerToast: () => {}, + addSuccessToast: () => {}, + onDashboardImport: () => {}, + show: true, + onHide: () => {}, +}; + +describe('ImportDashboardModal', () => { + let wrapper: ReactWrapper; + + beforeEach(() => { + wrapper = mount(, { + context: { store }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + expect(wrapper.find(ImportDashboardModal)).toExist(); + }); + + it('renders a Modal', () => { + expect(wrapper.find(Modal)).toExist(); + }); + + it('renders "Import Dashboard" header', () => { + expect(wrapper.find('h4').text()).toEqual('Import Dashboard'); + }); + + 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 = 'dashboardFile'; + 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)], 'dashboard_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/dashboard/components/ImportModal/index.tsx b/superset-frontend/src/dashboard/components/ImportModal/index.tsx new file mode 100644 index 0000000000000..0d8cf262e80ae --- /dev/null +++ b/superset-frontend/src/dashboard/components/ImportModal/index.tsx @@ -0,0 +1,190 @@ +/** + * 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 { t } from '@superset-ui/core'; + +import Modal from 'src/common/components/Modal'; +import { + StyledIcon, + StyledInputContainer, +} from 'src/views/CRUD/data/database/DatabaseModal'; +import { useImportResource } from 'src/views/CRUD/hooks'; +import { DashboardObject } from 'src/views/CRUD/dashboard/types'; + +export interface ImportDashboardModalProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; + onDashboardImport: () => void; + show: boolean; + onHide: () => void; + passwordFields?: string[]; + setPasswordFields?: (passwordFields: string[]) => void; +} + +const ImportDashboardModal: FunctionComponent = ({ + addDangerToast, + addSuccessToast, + onDashboardImport, + 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( + 'dashboard', + t('dashboard'), + handleErrorMsg, + ); + + useEffect(() => { + setPasswordFields(passwordsNeeded); + }, [passwordsNeeded]); + + // Functions + const hide = () => { + setIsHidden(true); + onHide(); + }; + + const onUpload = () => { + if (uploadFile === null) { + return; + } + + importResource(uploadFile, passwords).then(result => { + if (result) { + addSuccessToast(t('The dashboards have been imported')); + clearModal(); + onDashboardImport(); + } + }); + }; + + 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
+ +
+ {t( + 'The passwords for the databases below are needed in order to ' + + 'import them together with the dashboards. Please note that the ' + + '"Secure Extra" and "Certificate" sections of ' + + 'the database configuration are not present in export files, and ' + + 'should be added manually after the import if they are needed.', + )} +
+
+ {passwordFields.map(fileName => ( + +
+ {fileName} + * +
+ + setPasswords({ ...passwords, [fileName]: event.target.value }) + } + /> +
+ ))} + + ); + }; + + // Show/hide + if (isHidden && show) { + setIsHidden(false); + } + + return ( + + + {t('Import Dashboard')} + + } + > + +
+ +
+ +
+ {renderPasswordFields()} +
+ ); +}; + +export default ImportDashboardModal; diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx index 8b9f51267a35b..e6f73885e975c 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx +++ b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx @@ -37,6 +37,7 @@ import Icon from 'src/components/Icon'; import FaveStar from 'src/components/FaveStar'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; import TooltipWrapper from 'src/components/TooltipWrapper'; +import ImportDashboardModal from 'src/dashboard/components/ImportModal/index'; import Dashboard from 'src/dashboard/containers/Dashboard'; import DashboardCard from './DashboardCard'; @@ -93,6 +94,22 @@ function DashboardList(props: DashboardListProps) { null, ); + const [importingDashboard, showImportModal] = useState(false); + const [passwordFields, setPasswordFields] = useState([]); + + function openDashboardImportModal() { + showImportModal(true); + } + + function closeDashboardImportModal() { + showImportModal(false); + } + + const handleDashboardImport = () => { + showImportModal(false); + refreshData(); + }; + const canCreate = hasPerm('can_add'); const canEdit = hasPerm('can_edit'); const canDelete = hasPerm('can_delete'); @@ -439,6 +456,13 @@ function DashboardList(props: DashboardListProps) { }, }); } + if (isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT)) { + subMenuButtons.push({ + name: , + buttonStyle: 'link', + onClick: openDashboardImportModal, + }); + } return ( <> @@ -502,6 +526,16 @@ function DashboardList(props: DashboardListProps) { ); }} + + : ); } diff --git a/superset-frontend/src/views/CRUD/dashboard/types.ts b/superset-frontend/src/views/CRUD/dashboard/types.ts new file mode 100644 index 0000000000000..5f442effc10ad --- /dev/null +++ b/superset-frontend/src/views/CRUD/dashboard/types.ts @@ -0,0 +1,26 @@ +/** + * 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. + */ +export type DashboardObject = { + dashboard_title: string; + description?: string; + css?: string; + slug?: string; + position?: string; + metadata?: string; +}; diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index 7cd918c2fa6d1..3f8e3ba377b5e 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -14,6 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import json import logging from datetime import datetime from io import BytesIO @@ -697,7 +698,13 @@ def import_(self) -> Response: for file_name in bundle.namelist() } - command = ImportDashboardsCommand(contents) + passwords = ( + json.loads(request.form["passwords"]) + if "passwords" in request.form + else None + ) + + command = ImportDashboardsCommand(contents, passwords=passwords) try: command.run() return self.response(200, message="OK") diff --git a/superset/dashboards/commands/export.py b/superset/dashboards/commands/export.py index 9d0efec9323a4..345c0a74a9055 100644 --- a/superset/dashboards/commands/export.py +++ b/superset/dashboards/commands/export.py @@ -62,7 +62,7 @@ def export(model: Dashboard) -> Iterator[Tuple[str, str]]: payload[new_name] = json.loads(value) except (TypeError, json.decoder.JSONDecodeError): logger.info("Unable to decode `%s` field: %s", key, value) - payload[new_name] = "" + payload[new_name] = {} payload["version"] = EXPORT_VERSION diff --git a/superset/dashboards/commands/importers/dispatcher.py b/superset/dashboards/commands/importers/dispatcher.py index 91811c90fd429..59ab20488350d 100644 --- a/superset/dashboards/commands/importers/dispatcher.py +++ b/superset/dashboards/commands/importers/dispatcher.py @@ -46,12 +46,14 @@ class ImportDashboardsCommand(BaseCommand): # pylint: disable=unused-argument def __init__(self, contents: Dict[str, str], *args: Any, **kwargs: Any): self.contents = contents + self.args = args + self.kwargs = kwargs def run(self) -> None: # iterate over all commands until we find a version that can # handle the contents for version in command_versions: - command = version(self.contents) + command = version(self.contents, *self.args, **self.kwargs) try: command.run() return diff --git a/superset/dashboards/commands/importers/v1/__init__.py b/superset/dashboards/commands/importers/v1/__init__.py index 086fa0318d9a3..67f1e7790af84 100644 --- a/superset/dashboards/commands/importers/v1/__init__.py +++ b/superset/dashboards/commands/importers/v1/__init__.py @@ -39,6 +39,7 @@ from superset.databases.schemas import ImportV1DatabaseSchema from superset.datasets.commands.importers.v1.utils import import_dataset from superset.datasets.schemas import ImportV1DatasetSchema +from superset.models.core import Database from superset.models.dashboard import Dashboard, dashboard_slices schemas: Dict[str, Schema] = { @@ -67,6 +68,7 @@ class ImportDashboardsCommand(BaseCommand): # pylint: disable=unused-argument def __init__(self, contents: Dict[str, str], *args: Any, **kwargs: Any): self.contents = contents + self.passwords: Dict[str, str] = kwargs.get("passwords") or {} self._configs: Dict[str, Any] = {} # TODO (betodealmeida): refactor to use code from other commands @@ -162,6 +164,14 @@ def run(self) -> None: def validate(self) -> None: exceptions: List[ValidationError] = [] + # load existing databases so we can apply the password validation + db_passwords = { + str(uuid): password + for uuid, password in db.session.query( + Database.uuid, Database.password + ).all() + } + # verify that the metadata file is present and valid try: metadata: Optional[Dict[str, str]] = load_metadata(self.contents) @@ -169,12 +179,20 @@ def validate(self) -> None: exceptions.append(exc) metadata = None + # validate dashboards, charts, datasets, and databases for file_name, content in self.contents.items(): prefix = file_name.split("/")[0] schema = schemas.get(f"{prefix}/") if schema: try: config = load_yaml(file_name, content) + + # populate passwords from the request or from existing DBs + if file_name in self.passwords: + config["password"] = self.passwords[file_name] + elif prefix == "databases" and config["uuid"] in db_passwords: + config["password"] = db_passwords[config["uuid"]] + schema.load(config) self._configs[file_name] = config except ValidationError as exc: diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index c2937e8f3fdb2..c404dfa9a6013 100644 --- a/superset/dashboards/schemas.py +++ b/superset/dashboards/schemas.py @@ -181,7 +181,7 @@ class GetFavStarIdsSchema(Schema): class ImportV1DashboardSchema(Schema): dashboard_title = fields.String(required=True) description = fields.String(allow_none=True) - css = fields.String() + css = fields.String(allow_none=True) slug = fields.String(allow_none=True) uuid = fields.UUID(required=True) position = fields.Dict()