This is an automated email from the ASF dual-hosted git repository.
beto pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
The following commit(s) were added to refs/heads/master by this push:
new 8f1ac7e feat: add modal to import dashboards (#11924)
8f1ac7e is described below
commit 8f1ac7ead18d7df05dd0f307250caf592347baea
Author: Beto Dealmeida <[email protected]>
AuthorDate: Mon Dec 7 21:26:14 2020 -0800
feat: add modal to import dashboards (#11924)
---
.../components/ImportModal/ImportModal.test.tsx | 106 ++++++++++++
.../src/dashboard/components/ImportModal/index.tsx | 190 +++++++++++++++++++++
.../src/views/CRUD/dashboard/DashboardList.tsx | 34 ++++
.../src/views/CRUD/dashboard/types.ts | 26 +++
superset/dashboards/api.py | 9 +-
superset/dashboards/commands/export.py | 2 +-
.../dashboards/commands/importers/dispatcher.py | 4 +-
.../dashboards/commands/importers/v1/__init__.py | 18 ++
superset/dashboards/schemas.py | 2 +-
9 files changed, 387 insertions(+), 4 deletions(-)
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 0000000..ac46fd6
--- /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(<ImportDashboardModal {...requiredProps} />, {
+ 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(
+ <ImportDashboardModal
+ {...requiredProps}
+ passwordFields={['databases/examples.yaml']}
+ />,
+ {
+ 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 0000000..0d8cf26
--- /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<ImportDashboardModalProps> = ({
+ addDangerToast,
+ addSuccessToast,
+ onDashboardImport,
+ show,
+ onHide,
+ passwordFields = [],
+ setPasswordFields = () => {},
+}) => {
+ const [uploadFile, setUploadFile] = useState<File | null>(null);
+ const [isHidden, setIsHidden] = useState<boolean>(true);
+ const [passwords, setPasswords] = useState<Record<string, string>>({});
+ const fileInputRef = useRef<HTMLInputElement>(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<DashboardObject>(
+ '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<HTMLInputElement>) => {
+ const { files } = event.target as HTMLInputElement;
+ setUploadFile((files && files[0]) || null);
+ };
+
+ const renderPasswordFields = () => {
+ if (passwordFields.length === 0) {
+ return null;
+ }
+
+ return (
+ <>
+ <h5>Database passwords</h5>
+ <StyledInputContainer>
+ <div className="helper">
+ {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.',
+ )}
+ </div>
+ </StyledInputContainer>
+ {passwordFields.map(fileName => (
+ <StyledInputContainer key={`password-for-${fileName}`}>
+ <div className="control-label">
+ {fileName}
+ <span className="required">*</span>
+ </div>
+ <input
+ name={`password-${fileName}`}
+ autoComplete="off"
+ type="password"
+ value={passwords[fileName]}
+ onChange={event =>
+ setPasswords({ ...passwords, [fileName]: event.target.value })
+ }
+ />
+ </StyledInputContainer>
+ ))}
+ </>
+ );
+ };
+
+ // Show/hide
+ if (isHidden && show) {
+ setIsHidden(false);
+ }
+
+ return (
+ <Modal
+ name="dashboard"
+ className="dashboard-modal"
+ disablePrimaryButton={uploadFile === null}
+ onHandledPrimaryAction={onUpload}
+ onHide={hide}
+ primaryButtonName={t('Import')}
+ width="750px"
+ show={show}
+ title={
+ <h4>
+ <StyledIcon name="nav-dashboard" />
+ {t('Import Dashboard')}
+ </h4>
+ }
+ >
+ <StyledInputContainer>
+ <div className="control-label">
+ <label htmlFor="dashboardFile">
+ {t('File')}
+ <span className="required">*</span>
+ </label>
+ </div>
+ <input
+ ref={fileInputRef}
+ data-test="dashboard-file-input"
+ name="dashboardFile"
+ id="dashboardFile"
+ type="file"
+ accept=".yaml,.json,.yml,.zip"
+ onChange={changeFile}
+ />
+ </StyledInputContainer>
+ {renderPasswordFields()}
+ </Modal>
+ );
+};
+
+export default ImportDashboardModal;
diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx
b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx
index 8b9f512..e6f7388 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<boolean>(false);
+ const [passwordFields, setPasswordFields] = useState<string[]>([]);
+
+ 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: <Icon name="import" />,
+ buttonStyle: 'link',
+ onClick: openDashboardImportModal,
+ });
+ }
return (
<>
<SubMenu name={t('Dashboards')} buttons={subMenuButtons} />
@@ -502,6 +526,16 @@ function DashboardList(props: DashboardListProps) {
);
}}
</ConfirmStatusChange>
+ <ImportDashboardModal
+ show={importingDashboard}
+ onHide={closeDashboardImportModal}
+ addDangerToast={props.addDangerToast}
+ addSuccessToast={props.addSuccessToast}
+ onDashboardImport={handleDashboardImport}
+ passwordFields={passwordFields}
+ setPasswordFields={setPasswordFields}
+ />
+ :
</>
);
}
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 0000000..5f442ef
--- /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 7cd918c..3f8e3ba 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 @@ class DashboardRestApi(BaseSupersetModelRestApi):
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 9d0efec..345c0a7 100644
--- a/superset/dashboards/commands/export.py
+++ b/superset/dashboards/commands/export.py
@@ -62,7 +62,7 @@ class ExportDashboardsCommand(ExportModelsCommand):
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 91811c9..59ab204 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 086fa03..67f1e77 100644
--- a/superset/dashboards/commands/importers/v1/__init__.py
+++ b/superset/dashboards/commands/importers/v1/__init__.py
@@ -39,6 +39,7 @@ from superset.databases.commands.importers.v1.utils import
import_database
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 @@ class ImportDashboardsCommand(BaseCommand):
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 @@ class ImportDashboardsCommand(BaseCommand):
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 c2937e8..c404dfa 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()