You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by be...@apache.org on 2020/10/26 16:42:42 UTC

[incubator-superset] branch SO-1002 updated: Front end for VERSIONED_EXPORT (#11373)

This is an automated email from the ASF dual-hosted git repository.

beto pushed a commit to branch SO-1002
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git


The following commit(s) were added to refs/heads/SO-1002 by this push:
     new 168bd6c  Front end for VERSIONED_EXPORT (#11373)
168bd6c is described below

commit 168bd6ce55c2163e6c5692c3169a1867b2d3adf1
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Mon Oct 26 09:38:06 2020 -0700

    Front end for VERSIONED_EXPORT (#11373)
---
 superset-frontend/src/featureFlags.ts              |  1 +
 .../src/views/CRUD/chart/ChartList.tsx             | 70 +++++++++++++++++-----
 .../src/views/CRUD/data/database/DatabaseList.tsx  | 63 +++++++++++++------
 .../src/views/CRUD/data/dataset/DatasetList.tsx    | 61 ++++++++++++++-----
 superset/charts/api.py                             |  1 +
 superset/charts/commands/export.py                 |  6 +-
 superset/config.py                                 |  1 +
 superset/dashboards/commands/export.py             |  6 +-
 superset/databases/api.py                          |  1 +
 superset/datasets/api.py                           |  3 +
 superset/views/database/views.py                   |  4 +-
 11 files changed, 166 insertions(+), 51 deletions(-)

diff --git a/superset-frontend/src/featureFlags.ts b/superset-frontend/src/featureFlags.ts
index 46a3455..9c62256 100644
--- a/superset-frontend/src/featureFlags.ts
+++ b/superset-frontend/src/featureFlags.ts
@@ -28,6 +28,7 @@ export enum FeatureFlag {
   SQLLAB_BACKEND_PERSISTENCE = 'SQLLAB_BACKEND_PERSISTENCE',
   THUMBNAILS = 'THUMBNAILS',
   LISTVIEWS_DEFAULT_CARD_VIEW = 'LISTVIEWS_DEFAULT_CARD_VIEW',
+  VERSIONED_EXPORT = 'VERSIONED_EXPORT',
 }
 
 export type FeatureFlagMap = {
diff --git a/superset-frontend/src/views/CRUD/chart/ChartList.tsx b/superset-frontend/src/views/CRUD/chart/ChartList.tsx
index ae64ae5..e559525 100644
--- a/superset-frontend/src/views/CRUD/chart/ChartList.tsx
+++ b/superset-frontend/src/views/CRUD/chart/ChartList.tsx
@@ -113,6 +113,8 @@ function ChartList(props: ChartListProps) {
   const canCreate = hasPerm('can_add');
   const canEdit = hasPerm('can_edit');
   const canDelete = hasPerm('can_delete');
+  const canExport =
+    hasPerm('can_mulexport') && isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT);
   const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
 
   function openChartEditModal(chart: Chart) {
@@ -168,6 +170,14 @@ function ChartList(props: ChartListProps) {
     );
   }
 
+  function handleBulkChartExport(chartsToExport: Chart[]) {
+    return window.location.assign(
+      `/api/v1/chart/export/?q=${rison.encode(
+        chartsToExport.map(({ id }) => id),
+      )}`,
+    );
+  }
+
   function renderFaveStar(id: number) {
     return (
       <FaveStar
@@ -268,7 +278,8 @@ function ChartList(props: ChartListProps) {
         Cell: ({ row: { original } }: any) => {
           const handleDelete = () => handleChartDelete(original);
           const openEditModal = () => openChartEditModal(original);
-          if (!canEdit && !canDelete) {
+          const handleExport = () => handleBulkChartExport([original]);
+          if (!canEdit && !canDelete && !canExport) {
             return null;
           }
 
@@ -303,6 +314,22 @@ function ChartList(props: ChartListProps) {
                   )}
                 </ConfirmStatusChange>
               )}
+              {canExport && (
+                <TooltipWrapper
+                  label="export-action"
+                  tooltip={t('Export')}
+                  placement="bottom"
+                >
+                  <span
+                    role="button"
+                    tabIndex={0}
+                    className="action-button"
+                    onClick={handleExport}
+                  >
+                    <Icon name="share" />
+                  </span>
+                </TooltipWrapper>
+              )}
               {canEdit && (
                 <TooltipWrapper
                   label="edit-action"
@@ -327,7 +354,7 @@ function ChartList(props: ChartListProps) {
         disableSortBy: true,
       },
     ],
-    [canEdit, canDelete],
+    [canEdit, canDelete, canExport],
   );
 
   const filters: Filters = [
@@ -457,6 +484,15 @@ function ChartList(props: ChartListProps) {
             </ConfirmStatusChange>
           </Menu.Item>
         )}
+        {canExport && (
+          <Menu.Item
+            role="button"
+            tabIndex={0}
+            onClick={() => handleBulkChartExport([chart])}
+          >
+            <ListViewCard.MenuIcon name="share" /> Export
+          </Menu.Item>
+        )}
         {canEdit && (
           <Menu.Item
             data-test="chart-list-edit-option"
@@ -495,7 +531,7 @@ function ChartList(props: ChartListProps) {
     );
   }
   const subMenuButtons: SubMenuProps['buttons'] = [];
-  if (canDelete) {
+  if (canDelete || canExport) {
     subMenuButtons.push({
       name: t('Bulk Select'),
       buttonStyle: 'secondary',
@@ -533,17 +569,23 @@ function ChartList(props: ChartListProps) {
         onConfirm={handleBulkChartDelete}
       >
         {confirmDelete => {
-          const bulkActions: ListViewProps['bulkActions'] = canDelete
-            ? [
-                {
-                  key: 'delete',
-                  name: t('Delete'),
-                  onSelect: confirmDelete,
-                  type: 'danger',
-                },
-              ]
-            : [];
-
+          const bulkActions: ListViewProps['bulkActions'] = [];
+          if (canDelete) {
+            bulkActions.push({
+              key: 'delete',
+              name: t('Delete'),
+              type: 'danger',
+              onSelect: confirmDelete,
+            });
+          }
+          if (canExport) {
+            bulkActions.push({
+              key: 'export',
+              name: t('Export'),
+              type: 'primary',
+              onSelect: handleBulkChartExport,
+            });
+          }
           return (
             <ListView<Chart>
               bulkActions={bulkActions}
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
index 4b436ec..a5711b8 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
@@ -18,6 +18,8 @@
  */
 import { SupersetClient, t, styled } from '@superset-ui/core';
 import React, { useState, useMemo } from 'react';
+import rison from 'rison';
+import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
 import { useListViewResource } from 'src/views/CRUD/hooks';
 import { createErrorHandler } from 'src/views/CRUD/utils';
 import withToasts from 'src/messageToasts/enhancers/withToasts';
@@ -119,6 +121,8 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
   const canCreate = hasPerm('can_add');
   const canEdit = hasPerm('can_edit');
   const canDelete = hasPerm('can_delete');
+  const canExport =
+    hasPerm('can_mulexport') && isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT);
 
   const menuData: SubMenuProps = {
     activeChild: 'Databases',
@@ -144,6 +148,12 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
     ];
   }
 
+  function handleDatabaseExport(database: DatabaseObject) {
+    return window.location.assign(
+      `/api/v1/database/export/?q=${rison.encode([database.id])}`,
+    );
+  }
+
   const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
   const columns = useMemo(
     () => [
@@ -239,27 +249,12 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
         Cell: ({ row: { original } }: any) => {
           const handleEdit = () => handleDatabaseEdit(original);
           const handleDelete = () => openDatabaseDeleteModal(original);
-          if (!canEdit && !canDelete) {
+          const handleExport = () => handleDatabaseExport(original);
+          if (!canEdit && !canDelete && !canExport) {
             return null;
           }
           return (
             <span className="actions">
-              {canEdit && (
-                <TooltipWrapper
-                  label="edit-action"
-                  tooltip={t('Edit')}
-                  placement="bottom"
-                >
-                  <span
-                    role="button"
-                    tabIndex={0}
-                    className="action-button"
-                    onClick={handleEdit}
-                  >
-                    <Icon name="edit-alt" />
-                  </span>
-                </TooltipWrapper>
-              )}
               {canDelete && (
                 <span
                   role="button"
@@ -277,6 +272,38 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
                   </TooltipWrapper>
                 </span>
               )}
+              {canExport && (
+                <TooltipWrapper
+                  label="export-action"
+                  tooltip={t('Export')}
+                  placement="bottom"
+                >
+                  <span
+                    role="button"
+                    tabIndex={0}
+                    className="action-button"
+                    onClick={handleExport}
+                  >
+                    <Icon name="share" />
+                  </span>
+                </TooltipWrapper>
+              )}
+              {canEdit && (
+                <TooltipWrapper
+                  label="edit-action"
+                  tooltip={t('Edit')}
+                  placement="bottom"
+                >
+                  <span
+                    role="button"
+                    tabIndex={0}
+                    className="action-button"
+                    onClick={handleEdit}
+                  >
+                    <Icon name="edit-alt" />
+                  </span>
+                </TooltipWrapper>
+              )}
             </span>
           );
         },
@@ -285,7 +312,7 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
         disableSortBy: true,
       },
     ],
-    [canDelete, canEdit],
+    [canDelete, canEdit, canExport],
   );
 
   const filters: Filters = useMemo(
diff --git a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx
index 8b8fcb9..97be23b 100644
--- a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx
+++ b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx
@@ -103,6 +103,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
   const canEdit = hasPerm('can_edit');
   const canDelete = hasPerm('can_delete');
   const canCreate = hasPerm('can_add');
+  const canExport = hasPerm('can_mulexport');
 
   const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
 
@@ -250,7 +251,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
         Cell: ({ row: { original } }: any) => {
           const handleEdit = () => openDatasetEditModal(original);
           const handleDelete = () => openDatasetDeleteModal(original);
-          if (!canEdit && !canDelete) {
+          const handleExport = () => handleBulkDatasetExport([original]);
+          if (!canEdit && !canDelete && !canExport) {
             return null;
           }
           return (
@@ -271,7 +273,22 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
                   </span>
                 </TooltipWrapper>
               )}
-
+              {canExport && (
+                <TooltipWrapper
+                  label="export-action"
+                  tooltip={t('Export')}
+                  placement="bottom"
+                >
+                  <span
+                    role="button"
+                    tabIndex={0}
+                    className="action-button"
+                    onClick={handleExport}
+                  >
+                    <Icon name="share" />
+                  </span>
+                </TooltipWrapper>
+              )}
               {canEdit && (
                 <TooltipWrapper
                   label="edit-action"
@@ -296,7 +313,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
         disableSortBy: true,
       },
     ],
-    [canEdit, canDelete, openDatasetEditModal],
+    [canEdit, canDelete, canExport, openDatasetEditModal],
   );
 
   const filterTypes: Filters = useMemo(
@@ -377,7 +394,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
 
   const buttonArr: Array<ButtonProps> = [];
 
-  if (canDelete) {
+  if (canDelete || canExport) {
     buttonArr.push({
       name: t('Bulk Select'),
       onClick: toggleBulkSelect,
@@ -443,6 +460,14 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
     );
   };
 
+  const handleBulkDatasetExport = (datasetsToExport: Dataset[]) => {
+    return window.location.assign(
+      `/api/v1/dataset/export/?q=${rison.encode(
+        datasetsToExport.map(({ id }) => id),
+      )}`,
+    );
+  };
+
   return (
     <>
       <SubMenu {...menuData} />
@@ -485,17 +510,23 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
         onConfirm={handleBulkDatasetDelete}
       >
         {confirmDelete => {
-          const bulkActions: ListViewProps['bulkActions'] = canDelete
-            ? [
-                {
-                  key: 'delete',
-                  name: t('Delete'),
-                  onSelect: confirmDelete,
-                  type: 'danger',
-                },
-              ]
-            : [];
-
+          const bulkActions: ListViewProps['bulkActions'] = [];
+          if (canDelete) {
+            bulkActions.push({
+              key: 'delete',
+              name: t('Delete'),
+              onSelect: confirmDelete,
+              type: 'danger',
+            });
+          }
+          if (canExport) {
+            bulkActions.push({
+              key: 'export',
+              name: t('Export'),
+              type: 'primary',
+              onSelect: handleBulkDatasetExport,
+            });
+          }
           return (
             <ListView<Dataset>
               className="dataset-list-view"
diff --git a/superset/charts/api.py b/superset/charts/api.py
index 3ef27d0..b5873aa 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -174,6 +174,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
     apispec_parameter_schemas = {
         "screenshot_query_schema": screenshot_query_schema,
         "get_delete_ids_schema": get_delete_ids_schema,
+        "get_export_ids_schema": get_export_ids_schema,
     }
     """ Add extra schemas to the OpenAPI components schema section """
     openapi_spec_methods = openapi_spec_methods_override
diff --git a/superset/charts/commands/export.py b/superset/charts/commands/export.py
index 00e0fd4..807a0a3 100644
--- a/superset/charts/commands/export.py
+++ b/superset/charts/commands/export.py
@@ -74,8 +74,12 @@ class ExportChartsCommand(BaseCommand):
     def run(self) -> Iterator[Tuple[str, str]]:
         self.validate()
 
+        seen = set()
         for chart in self._models:
-            yield from self.export_chart(chart)
+            for file_name, file_content in self.export_chart(chart):
+                if file_name not in seen:
+                    yield file_name, file_content
+                    seen.add(file_name)
 
     def validate(self) -> None:
         self._models = ChartDAO.find_by_ids(self.chart_ids)
diff --git a/superset/config.py b/superset/config.py
index 8554e18..4f48ab8 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -310,6 +310,7 @@ DEFAULT_FEATURE_FLAGS: Dict[str, bool] = {
     "TAGGING_SYSTEM": False,
     "SQLLAB_BACKEND_PERSISTENCE": False,
     "LISTVIEWS_DEFAULT_CARD_VIEW": False,
+    "VERSIONED_EXPORT": False,
 }
 
 # Set the default view to card/grid view if thumbnail support is enabled.
diff --git a/superset/dashboards/commands/export.py b/superset/dashboards/commands/export.py
index 0f3cbc1..60fb0d3 100644
--- a/superset/dashboards/commands/export.py
+++ b/superset/dashboards/commands/export.py
@@ -71,8 +71,12 @@ class ExportDashboardsCommand(BaseCommand):
     def run(self) -> Iterator[Tuple[str, str]]:
         self.validate()
 
+        seen = set()
         for dashboard in self._models:
-            yield from self.export_dashboard(dashboard)
+            for file_name, file_content in self.export_dashboard(dashboard):
+                if file_name not in seen:
+                    yield file_name, file_content
+                    seen.add(file_name)
 
     def validate(self) -> None:
         self._models = DashboardDAO.find_by_ids(self.dashboard_ids)
diff --git a/superset/databases/api.py b/superset/databases/api.py
index ca1eb3b..f043455 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -165,6 +165,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
 
     apispec_parameter_schemas = {
         "database_schemas_query_schema": database_schemas_query_schema,
+        "get_export_ids_schema": get_export_ids_schema,
     }
     openapi_spec_tag = "Database"
     openapi_spec_component_schemas = (
diff --git a/superset/datasets/api.py b/superset/datasets/api.py
index fff69bf..e7bf184 100644
--- a/superset/datasets/api.py
+++ b/superset/datasets/api.py
@@ -164,6 +164,9 @@ class DatasetRestApi(BaseSupersetModelRestApi):
     allowed_rel_fields = {"database", "owners"}
     allowed_distinct_fields = {"schema"}
 
+    apispec_parameter_schemas = {
+        "get_export_ids_schema": get_export_ids_schema,
+    }
     openapi_spec_component_schemas = (DatasetRelatedObjectsResponse,)
 
     @expose("/", methods=["POST"])
diff --git a/superset/views/database/views.py b/superset/views/database/views.py
index 0961381..a903c9b 100644
--- a/superset/views/database/views.py
+++ b/superset/views/database/views.py
@@ -50,7 +50,7 @@ stats_logger = config["STATS_LOGGER"]
 
 def sqlalchemy_uri_form_validator(_: _, field: StringField) -> None:
     """
-        Check if user has submitted a valid SQLAlchemy URI
+    Check if user has submitted a valid SQLAlchemy URI
     """
 
     sqlalchemy_uri_validator(field.data, exception=ValidationError)
@@ -58,7 +58,7 @@ def sqlalchemy_uri_form_validator(_: _, field: StringField) -> None:
 
 def certificate_form_validator(_: _, field: StringField) -> None:
     """
-        Check if user has submitted a valid SSL certificate
+    Check if user has submitted a valid SSL certificate
     """
     if field.data:
         try: