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()

Reply via email to