From b23788a4dfb0111cd0e2ad05c24969268b2268a5 Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Thu, 10 Jun 2021 16:55:39 -0700 Subject: [PATCH 1/7] feat: show spinner on exports --- .../views/CRUD/data/database/DatabaseList.tsx | 20 ++++++++++++++++++- superset/databases/api.py | 17 ++++++++++------ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx index fda65baa27038..21be8e2c74bb1 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx @@ -19,6 +19,9 @@ import { SupersetClient, t, styled } from '@superset-ui/core'; import React, { useState, useMemo } from 'react'; import rison from 'rison'; +import shortid from 'shortid'; +import Loading from 'src/components/Loading'; +import parseCookie from 'src/utils/parseCookie'; import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import { useListViewResource } from 'src/views/CRUD/hooks'; import { createErrorHandler } from 'src/views/CRUD/utils'; @@ -97,6 +100,7 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { ); const [importingDatabase, showImportModal] = useState(false); const [passwordFields, setPasswordFields] = useState([]); + const [preparingExport, setPreparingExport] = useState(false); const openDatabaseImportModal = () => { showImportModal(true); @@ -168,6 +172,8 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { ...commonMenuData, }; + let exportTimer: number; + if (canCreate) { menuData.buttons = [ { @@ -203,8 +209,19 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { } function handleDatabaseExport(database: DatabaseObject) { + const token = shortid.generate(); + exportTimer = window.setInterval(() => { + const cookie = parseCookie(); + if (cookie[token] === 'done') { + setPreparingExport(false); + window.clearInterval(exportTimer); + } + }, 200); + setPreparingExport(true); return window.location.assign( - `/api/v1/database/export/?q=${rison.encode([database.id])}`, + `/api/v1/database/export/?q=${rison.encode([ + database.id, + ])}&token=${token}`, ); } @@ -469,6 +486,7 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { passwordFields={passwordFields} setPasswordFields={setPasswordFields} /> + {preparingExport && } ); } diff --git a/superset/databases/api.py b/superset/databases/api.py index d64238baf4a3f..4c580ed0a7fc3 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -21,7 +21,7 @@ from typing import Any, Dict, List, Optional from zipfile import ZipFile -from flask import g, request, Response, send_file +from flask import g, make_response, request, Response, send_file from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.models.sqla.interface import SQLAInterface from marshmallow import ValidationError @@ -722,6 +722,7 @@ def export(self, **kwargs: Any) -> Response: 500: $ref: '#/components/responses/500' """ + token = request.args.get("token") requested_ids = kwargs["rison"] timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") root = f"database_export_{timestamp}" @@ -739,12 +740,16 @@ def export(self, **kwargs: Any) -> Response: return self.response_404() buf.seek(0) - return send_file( - buf, - mimetype="application/zip", - as_attachment=True, - attachment_filename=filename, + response = make_response( + send_file( + buf, + mimetype="application/zip", + as_attachment=True, + attachment_filename=filename, + ) ) + response.set_cookie(token, "done") + return response @expose("/import/", methods=["POST"]) @protect() From 49a00535ab07bc8603d3f84e3251652f395aa258 Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Thu, 10 Jun 2021 17:38:30 -0700 Subject: [PATCH 2/7] Set cookie only if token is passed --- superset/databases/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/superset/databases/api.py b/superset/databases/api.py index 4c580ed0a7fc3..a406ec8034096 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -748,7 +748,8 @@ def export(self, **kwargs: Any) -> Response: attachment_filename=filename, ) ) - response.set_cookie(token, "done") + if token: + response.set_cookie(token, "done") return response @expose("/import/", methods=["POST"]) From b2a82d65ba6ef7bbcb2075dcd162e94e74012c0d Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Fri, 11 Jun 2021 10:31:47 -0700 Subject: [PATCH 3/7] Use iframe --- superset-frontend/src/utils/export.ts | 48 +++++++++++++++++++ .../src/views/CRUD/chart/ChartCard.tsx | 4 +- .../src/views/CRUD/chart/ChartList.tsx | 14 +++++- .../views/CRUD/dashboard/DashboardCard.tsx | 8 ++-- .../views/CRUD/dashboard/DashboardList.tsx | 14 +++++- .../views/CRUD/data/database/DatabaseList.tsx | 26 ++++------ .../views/CRUD/data/dataset/DatasetList.tsx | 17 ++++--- .../CRUD/data/savedquery/SavedQueryList.tsx | 15 +++++- superset-frontend/src/views/CRUD/utils.tsx | 26 ---------- .../src/views/CRUD/welcome/ChartTable.tsx | 12 +++++ .../src/views/CRUD/welcome/DashboardTable.tsx | 12 +++++ superset/charts/api.py | 6 ++- superset/dashboards/api.py | 6 ++- superset/databases/api.py | 16 +++---- superset/datasets/api.py | 6 ++- superset/queries/saved_queries/api.py | 6 ++- 16 files changed, 164 insertions(+), 72 deletions(-) create mode 100644 superset-frontend/src/utils/export.ts diff --git a/superset-frontend/src/utils/export.ts b/superset-frontend/src/utils/export.ts new file mode 100644 index 0000000000000..45d78ac9712f8 --- /dev/null +++ b/superset-frontend/src/utils/export.ts @@ -0,0 +1,48 @@ +/** + * 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 parseCookie from 'src/utils/parseCookie'; +import rison from 'rison'; +import shortid from 'shortid'; + +export default function handleResourceExport( + resource: string, + ids: number[], + done: () => void, + interval = 200, +): void { + const token = shortid.generate(); + const url = `/api/v1/${resource}/export/?q=${rison.encode( + ids, + )}&token=${token}`; + + // create new iframe for export + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + iframe.src = url; + document.body.appendChild(iframe); + + const timer = window.setInterval(() => { + const cookie = parseCookie(); + if (cookie[token] === 'done') { + window.clearInterval(timer); + document.body.removeChild(iframe); + done(); + } + }, interval); +} diff --git a/superset-frontend/src/views/CRUD/chart/ChartCard.tsx b/superset-frontend/src/views/CRUD/chart/ChartCard.tsx index cc4f066f95d1a..4a04638794a68 100644 --- a/superset-frontend/src/views/CRUD/chart/ChartCard.tsx +++ b/superset-frontend/src/views/CRUD/chart/ChartCard.tsx @@ -29,7 +29,7 @@ import Label from 'src/components/Label'; import { Dropdown, Menu } from 'src/common/components'; import FaveStar from 'src/components/FaveStar'; import FacePile from 'src/components/FacePile'; -import { handleChartDelete, handleBulkChartExport, CardStyles } from '../utils'; +import { handleChartDelete, CardStyles } from '../utils'; interface ChartCardProps { chart: Chart; @@ -45,6 +45,7 @@ interface ChartCardProps { chartFilter?: string; userId?: number; showThumbnails?: boolean; + handleBulkChartExport: (chartsToExport: Chart[]) => void; } export default function ChartCard({ @@ -61,6 +62,7 @@ export default function ChartCard({ favoriteStatus, chartFilter, userId, + handleBulkChartExport, }: ChartCardProps) { const canEdit = hasPerm('can_write'); const canDelete = hasPerm('can_write'); diff --git a/superset-frontend/src/views/CRUD/chart/ChartList.tsx b/superset-frontend/src/views/CRUD/chart/ChartList.tsx index 4f2211ebe728d..7af94b7060732 100644 --- a/superset-frontend/src/views/CRUD/chart/ChartList.tsx +++ b/superset-frontend/src/views/CRUD/chart/ChartList.tsx @@ -29,7 +29,6 @@ import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; import { createErrorHandler, createFetchRelated, - handleBulkChartExport, handleChartDelete, } from 'src/views/CRUD/utils'; import { @@ -37,6 +36,7 @@ import { useFavoriteStatus, useListViewResource, } from 'src/views/CRUD/hooks'; +import handleResourceExport from 'src/utils/export'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu'; import FaveStar from 'src/components/FaveStar'; @@ -47,6 +47,7 @@ import ListView, { ListViewProps, SelectOption, } from 'src/components/ListView'; +import Loading from 'src/components/Loading'; import { getFromLocalStorage } from 'src/utils/localStorageHelpers'; import withToasts from 'src/messageToasts/enhancers/withToasts'; import PropertiesModal from 'src/explore/components/PropertiesModal'; @@ -156,6 +157,7 @@ function ChartList(props: ChartListProps) { const [importingChart, showImportModal] = useState(false); const [passwordFields, setPasswordFields] = useState([]); + const [preparingExport, setPreparingExport] = useState(false); const openChartImportModal = () => { showImportModal(true); @@ -177,6 +179,14 @@ function ChartList(props: ChartListProps) { hasPerm('can_read') && isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT); const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }]; + const handleBulkChartExport = (chartsToExport: Chart[]) => { + const ids = chartsToExport.map(({ id }) => id); + handleResourceExport('chart', ids, () => { + setPreparingExport(false); + }); + setPreparingExport(true); + }; + function handleBulkChartDelete(chartsToDelete: Chart[]) { SupersetClient.delete({ endpoint: `/api/v1/chart/?q=${rison.encode( @@ -540,6 +550,7 @@ function ChartList(props: ChartListProps) { loading={loading} favoriteStatus={favoriteStatus[chart.id]} saveFavoriteStatus={saveFavoriteStatus} + handleBulkChartExport={handleBulkChartExport} /> ); } @@ -653,6 +664,7 @@ function ChartList(props: ChartListProps) { passwordFields={passwordFields} setPasswordFields={setPasswordFields} /> + {preparingExport && } ); } diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardCard.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardCard.tsx index 93de8ead611d0..7b5d792ed4a1d 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardCard.tsx +++ b/superset-frontend/src/views/CRUD/dashboard/DashboardCard.tsx @@ -19,11 +19,7 @@ import React from 'react'; import { Link, useHistory } from 'react-router-dom'; import { t } from '@superset-ui/core'; -import { - handleDashboardDelete, - handleBulkDashboardExport, - CardStyles, -} from 'src/views/CRUD/utils'; +import { handleDashboardDelete, CardStyles } from 'src/views/CRUD/utils'; import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import { Dropdown, Menu } from 'src/common/components'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; @@ -49,6 +45,7 @@ interface DashboardCardProps { dashboardFilter?: string; userId?: number; showThumbnails?: boolean; + handleBulkDashboardExport: (dashboardsToExport: Dashboard[]) => void; } function DashboardCard({ @@ -64,6 +61,7 @@ function DashboardCard({ favoriteStatus, saveFavoriteStatus, showThumbnails, + handleBulkDashboardExport, }: DashboardCardProps) { const history = useHistory(); const canEdit = hasPerm('can_write'); diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx index 9831c26405905..26c09f2eb1725 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx +++ b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx @@ -25,10 +25,11 @@ import { createFetchRelated, createErrorHandler, handleDashboardDelete, - handleBulkDashboardExport, } from 'src/views/CRUD/utils'; import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; +import handleResourceExport from 'src/utils/export'; +import Loading from 'src/components/Loading'; import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu'; import ListView, { ListViewProps, @@ -123,6 +124,7 @@ function DashboardList(props: DashboardListProps) { const [importingDashboard, showImportModal] = useState(false); const [passwordFields, setPasswordFields] = useState([]); + const [preparingExport, setPreparingExport] = useState(false); const openDashboardImportModal = () => { showImportModal(true); @@ -170,6 +172,14 @@ function DashboardList(props: DashboardListProps) { ); } + const handleBulkDashboardExport = (dashboardsToExport: Dashboard[]) => { + const ids = dashboardsToExport.map(({ id }) => id); + handleResourceExport('chart', ids, () => { + setPreparingExport(false); + }); + setPreparingExport(true); + }; + function handleBulkDashboardDelete(dashboardsToDelete: Dashboard[]) { return SupersetClient.delete({ endpoint: `/api/v1/dashboard/?q=${rison.encode( @@ -487,6 +497,7 @@ function DashboardList(props: DashboardListProps) { openDashboardEditModal={openDashboardEditModal} saveFavoriteStatus={saveFavoriteStatus} favoriteStatus={favoriteStatus[dashboard.id]} + handleBulkDashboardExport={handleBulkDashboardExport} /> ); } @@ -605,6 +616,7 @@ function DashboardList(props: DashboardListProps) { passwordFields={passwordFields} setPasswordFields={setPasswordFields} /> + {preparingExport && } ); } diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx index 21be8e2c74bb1..d3be18978ca3a 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx @@ -18,10 +18,7 @@ */ import { SupersetClient, t, styled } from '@superset-ui/core'; import React, { useState, useMemo } from 'react'; -import rison from 'rison'; -import shortid from 'shortid'; import Loading from 'src/components/Loading'; -import parseCookie from 'src/utils/parseCookie'; import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import { useListViewResource } from 'src/views/CRUD/hooks'; import { createErrorHandler } from 'src/views/CRUD/utils'; @@ -33,6 +30,7 @@ import Icons from 'src/components/Icons'; import ListView, { FilterOperator, Filters } from 'src/components/ListView'; import { commonMenuData } from 'src/views/CRUD/data/common'; import ImportModelsModal from 'src/components/ImportModal/index'; +import handleResourceExport from 'src/utils/export'; import DatabaseModal from './DatabaseModal'; import { DatabaseObject } from './types'; @@ -172,8 +170,6 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { ...commonMenuData, }; - let exportTimer: number; - if (canCreate) { menuData.buttons = [ { @@ -209,20 +205,14 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { } function handleDatabaseExport(database: DatabaseObject) { - const token = shortid.generate(); - exportTimer = window.setInterval(() => { - const cookie = parseCookie(); - if (cookie[token] === 'done') { - setPreparingExport(false); - window.clearInterval(exportTimer); - } - }, 200); + if (database.id === undefined) { + return; + } + + handleResourceExport('database', [database.id], () => { + setPreparingExport(false); + }); setPreparingExport(true); - return window.location.assign( - `/api/v1/database/export/?q=${rison.encode([ - database.id, - ])}&token=${token}`, - ); } const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }]; diff --git a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx index 3f3ab8d39714b..7d5fbb2852be2 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx @@ -33,11 +33,13 @@ import { useListViewResource } from 'src/views/CRUD/hooks'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import DatasourceModal from 'src/datasource/DatasourceModal'; import DeleteModal from 'src/components/DeleteModal'; +import handleResourceExport from 'src/utils/export'; import ListView, { ListViewProps, Filters, FilterOperator, } from 'src/components/ListView'; +import Loading from 'src/components/Loading'; import SubMenu, { SubMenuProps, ButtonProps, @@ -131,6 +133,7 @@ const DatasetList: FunctionComponent = ({ const [importingDataset, showImportModal] = useState(false); const [passwordFields, setPasswordFields] = useState([]); + const [preparingExport, setPreparingExport] = useState(false); const openDatasetImportModal = () => { showImportModal(true); @@ -547,12 +550,13 @@ const DatasetList: FunctionComponent = ({ ); }; - const handleBulkDatasetExport = (datasetsToExport: Dataset[]) => - window.location.assign( - `/api/v1/dataset/export/?q=${rison.encode( - datasetsToExport.map(({ id }) => id), - )}`, - ); + const handleBulkDatasetExport = (datasetsToExport: Dataset[]) => { + const ids = datasetsToExport.map(({ id }) => id); + handleResourceExport('dataset', ids, () => { + setPreparingExport(false); + }); + setPreparingExport(true); + }; return ( <> @@ -682,6 +686,7 @@ const DatasetList: FunctionComponent = ({ passwordFields={passwordFields} setPasswordFields={setPasswordFields} /> + {preparingExport && } ); }; diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx index 04a418b39dc5d..0fe8a6b52c2f5 100644 --- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx +++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx @@ -25,12 +25,12 @@ import { createFetchRelated, createFetchDistinct, createErrorHandler, - handleBulkSavedQueryExport, } from 'src/views/CRUD/utils'; import Popover from 'src/components/Popover'; import withToasts from 'src/messageToasts/enhancers/withToasts'; import { useListViewResource } from 'src/views/CRUD/hooks'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; +import handleResourceExport from 'src/utils/export'; import SubMenu, { SubMenuProps, ButtonProps, @@ -40,6 +40,7 @@ import ListView, { Filters, FilterOperator, } from 'src/components/ListView'; +import Loading from 'src/components/Loading'; import DeleteModal from 'src/components/DeleteModal'; import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; import { Tooltip } from 'src/components/Tooltip'; @@ -117,6 +118,7 @@ function SavedQueryList({ ] = useState(null); const [importingSavedQuery, showImportModal] = useState(false); const [passwordFields, setPasswordFields] = useState([]); + const [preparingExport, setPreparingExport] = useState(false); const openSavedQueryImportModal = () => { showImportModal(true); @@ -238,6 +240,16 @@ function SavedQueryList({ ); }; + const handleBulkSavedQueryExport = ( + savedQueriesToExport: SavedQueryObject[], + ) => { + const ids = savedQueriesToExport.map(({ id }) => id); + handleResourceExport('chart', ids, () => { + setPreparingExport(false); + }); + setPreparingExport(true); + }; + const handleBulkQueryDelete = (queriesToDelete: SavedQueryObject[]) => { SupersetClient.delete({ endpoint: `/api/v1/saved_query/?q=${rison.encode( @@ -542,6 +554,7 @@ function SavedQueryList({ passwordFields={passwordFields} setPasswordFields={setPasswordFields} /> + {preparingExport && } ); } diff --git a/superset-frontend/src/views/CRUD/utils.tsx b/superset-frontend/src/views/CRUD/utils.tsx index 7e575c425aa8b..89d62481abe21 100644 --- a/superset-frontend/src/views/CRUD/utils.tsx +++ b/superset-frontend/src/views/CRUD/utils.tsx @@ -219,32 +219,6 @@ export function handleChartDelete( ); } -export function handleBulkChartExport(chartsToExport: Chart[]) { - return window.location.assign( - `/api/v1/chart/export/?q=${rison.encode( - chartsToExport.map(({ id }) => id), - )}`, - ); -} - -export function handleBulkDashboardExport(dashboardsToExport: Dashboard[]) { - return window.location.assign( - `/api/v1/dashboard/export/?q=${rison.encode( - dashboardsToExport.map(({ id }) => id), - )}`, - ); -} - -export function handleBulkSavedQueryExport( - savedQueriesToExport: SavedQueryObject[], -) { - return window.location.assign( - `/api/v1/saved_query/export/?q=${rison.encode( - savedQueriesToExport.map(({ id }) => id), - )}`, - ); -} - export function handleDashboardDelete( { id, dashboard_title: dashboardTitle }: Dashboard, refreshData: (config?: FetchDataConfig | null) => void, diff --git a/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx b/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx index 0fefac6752270..dd574413f539d 100644 --- a/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx +++ b/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx @@ -33,6 +33,7 @@ import PropertiesModal from 'src/explore/components/PropertiesModal'; import { User } from 'src/types/bootstrapTypes'; import ChartCard from 'src/views/CRUD/chart/ChartCard'; import Chart from 'src/types/Chart'; +import handleResourceExport from 'src/utils/export'; import Loading from 'src/components/Loading'; import ErrorBoundary from 'src/components/ErrorBoundary'; import SubMenu from 'src/components/Menu/SubMenu'; @@ -90,6 +91,7 @@ function ChartTable({ } = useChartEditModal(setCharts, charts); const [chartFilter, setChartFilter] = useState('Mine'); + const [preparingExport, setPreparingExport] = useState(false); useEffect(() => { const filter = getFromLocalStorage('chart', null); @@ -98,6 +100,14 @@ function ChartTable({ } else setChartFilter(filter.tab); }, []); + const handleBulkChartExport = (chartsToExport: Chart[]) => { + const ids = chartsToExport.map(({ id }) => id); + handleResourceExport('chart', ids, () => { + setPreparingExport(false); + }); + setPreparingExport(true); + }; + const getFilters = (filterName: string) => { const filters = []; @@ -208,12 +218,14 @@ function ChartTable({ addSuccessToast={addSuccessToast} favoriteStatus={favoriteStatus[e.id]} saveFavoriteStatus={saveFavoriteStatus} + handleBulkChartExport={handleBulkChartExport} /> ))} ) : ( )} + {preparingExport && } ); } diff --git a/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx b/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx index 4a84148a6029e..a9c773f343897 100644 --- a/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx +++ b/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx @@ -20,6 +20,7 @@ import React, { useState, useMemo, useEffect } from 'react'; import { SupersetClient, t } from '@superset-ui/core'; import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks'; import { Dashboard, DashboardTableProps } from 'src/views/CRUD/types'; +import handleResourceExport from 'src/utils/export'; import { useHistory } from 'react-router-dom'; import { setInLocalStorage, @@ -72,6 +73,7 @@ function DashboardTable({ ); const [editModal, setEditModal] = useState(); const [dashboardFilter, setDashboardFilter] = useState('Mine'); + const [preparingExport, setPreparingExport] = useState(false); useEffect(() => { const filter = getFromLocalStorage('dashboard', null); @@ -80,6 +82,14 @@ function DashboardTable({ } else setDashboardFilter(filter.tab); }, []); + const handleBulkDashboardExport = (dashboardsToExport: Dashboard[]) => { + const ids = dashboardsToExport.map(({ id }) => id); + handleResourceExport('chart', ids, () => { + setPreparingExport(false); + }); + setPreparingExport(true); + }; + const handleDashboardEdit = (edits: Dashboard) => SupersetClient.get({ endpoint: `/api/v1/dashboard/${edits.id}`, @@ -221,6 +231,7 @@ function DashboardTable({ } saveFavoriteStatus={saveFavoriteStatus} favoriteStatus={favoriteStatus[e.id]} + handleBulkDashboardExport={handleBulkDashboardExport} /> ))} @@ -228,6 +239,7 @@ function DashboardTable({ {dashboards.length === 0 && ( )} + {preparingExport && } ); } diff --git a/superset/charts/api.py b/superset/charts/api.py index bb92b3da20efa..f248f161adc07 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -903,6 +903,7 @@ def export(self, **kwargs: Any) -> Response: 500: $ref: '#/components/responses/500' """ + token = request.args.get("token") requested_ids = kwargs["rison"] timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") root = f"chart_export_{timestamp}" @@ -918,12 +919,15 @@ def export(self, **kwargs: Any) -> Response: return self.response_404() buf.seek(0) - return send_file( + response = send_file( buf, mimetype="application/zip", as_attachment=True, attachment_filename=filename, ) + if token: + response.set_cookie(token, "done", max_age=600) + return response @expose("/favorite_status/", methods=["GET"]) @protect() diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index 632f7f2c6464e..a581a2d5c9e0c 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -706,6 +706,7 @@ def export(self, **kwargs: Any) -> Response: requested_ids = kwargs["rison"] if is_feature_enabled("VERSIONED_EXPORT"): + token = request.args.get("token") timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") root = f"dashboard_export_{timestamp}" filename = f"{root}.zip" @@ -722,12 +723,15 @@ def export(self, **kwargs: Any) -> Response: return self.response_404() buf.seek(0) - return send_file( + response = send_file( buf, mimetype="application/zip", as_attachment=True, attachment_filename=filename, ) + if token: + response.set_cookie(token, "done", max_age=600) + return response query = self.datamodel.session.query(Dashboard).filter( Dashboard.id.in_(requested_ids) diff --git a/superset/databases/api.py b/superset/databases/api.py index a406ec8034096..a4c8f79a6cd2b 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -21,7 +21,7 @@ from typing import Any, Dict, List, Optional from zipfile import ZipFile -from flask import g, make_response, request, Response, send_file +from flask import g, request, Response, send_file from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.models.sqla.interface import SQLAInterface from marshmallow import ValidationError @@ -740,16 +740,14 @@ def export(self, **kwargs: Any) -> Response: return self.response_404() buf.seek(0) - response = make_response( - send_file( - buf, - mimetype="application/zip", - as_attachment=True, - attachment_filename=filename, - ) + response = send_file( + buf, + mimetype="application/zip", + as_attachment=True, + attachment_filename=filename, ) if token: - response.set_cookie(token, "done") + response.set_cookie(token, "done", max_age=600) return response @expose("/import/", methods=["POST"]) diff --git a/superset/datasets/api.py b/superset/datasets/api.py index 312369c609e3e..042ca929440f6 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -432,6 +432,7 @@ def export(self, **kwargs: Any) -> Response: requested_ids = kwargs["rison"] if is_feature_enabled("VERSIONED_EXPORT"): + token = request.args.get("token") timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") root = f"dataset_export_{timestamp}" filename = f"{root}.zip" @@ -448,12 +449,15 @@ def export(self, **kwargs: Any) -> Response: return self.response_404() buf.seek(0) - return send_file( + response = send_file( buf, mimetype="application/zip", as_attachment=True, attachment_filename=filename, ) + if token: + response.set_cookie(token, "done", max_age=600) + return response query = self.datamodel.session.query(SqlaTable).filter( SqlaTable.id.in_(requested_ids) diff --git a/superset/queries/saved_queries/api.py b/superset/queries/saved_queries/api.py index 97b123e344421..eb484cc94ccd9 100644 --- a/superset/queries/saved_queries/api.py +++ b/superset/queries/saved_queries/api.py @@ -237,6 +237,7 @@ def export(self, **kwargs: Any) -> Response: 500: $ref: '#/components/responses/500' """ + token = request.args.get("token") requested_ids = kwargs["rison"] timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") root = f"saved_query_export_{timestamp}" @@ -254,12 +255,15 @@ def export(self, **kwargs: Any) -> Response: return self.response_404() buf.seek(0) - return send_file( + response = send_file( buf, mimetype="application/zip", as_attachment=True, attachment_filename=filename, ) + if token: + response.set_cookie(token, "done", max_age=600) + return response @expose("/import/", methods=["POST"]) @protect() From 55f4e9ee90766489e87f732c06a64fa3426bcb63 Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Fri, 11 Jun 2021 11:37:20 -0700 Subject: [PATCH 4/7] Small fixes --- superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx | 2 +- .../src/views/CRUD/data/savedquery/SavedQueryList.tsx | 2 +- superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx index 26c09f2eb1725..daef59cb195bf 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx +++ b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx @@ -174,7 +174,7 @@ function DashboardList(props: DashboardListProps) { const handleBulkDashboardExport = (dashboardsToExport: Dashboard[]) => { const ids = dashboardsToExport.map(({ id }) => id); - handleResourceExport('chart', ids, () => { + handleResourceExport('dashboard', ids, () => { setPreparingExport(false); }); setPreparingExport(true); diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx index 0fe8a6b52c2f5..9c18e9df19f3a 100644 --- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx +++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx @@ -244,7 +244,7 @@ function SavedQueryList({ savedQueriesToExport: SavedQueryObject[], ) => { const ids = savedQueriesToExport.map(({ id }) => id); - handleResourceExport('chart', ids, () => { + handleResourceExport('saved_query', ids, () => { setPreparingExport(false); }); setPreparingExport(true); diff --git a/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx b/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx index a9c773f343897..f53408a0fd7dd 100644 --- a/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx +++ b/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx @@ -84,7 +84,7 @@ function DashboardTable({ const handleBulkDashboardExport = (dashboardsToExport: Dashboard[]) => { const ids = dashboardsToExport.map(({ id }) => id); - handleResourceExport('chart', ids, () => { + handleResourceExport('dashboard', ids, () => { setPreparingExport(false); }); setPreparingExport(true); From 3b0f65e6dfd9389542cb43b94f4d2ede1927e368 Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Fri, 11 Jun 2021 11:54:37 -0700 Subject: [PATCH 5/7] Fix lint --- superset-frontend/src/views/CRUD/utils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/src/views/CRUD/utils.tsx b/superset-frontend/src/views/CRUD/utils.tsx index 89d62481abe21..316f534d681dd 100644 --- a/superset-frontend/src/views/CRUD/utils.tsx +++ b/superset-frontend/src/views/CRUD/utils.tsx @@ -29,7 +29,7 @@ import rison from 'rison'; import { getClientErrorObject } from 'src/utils/getClientErrorObject'; import { FetchDataConfig } from 'src/components/ListView'; import SupersetText from 'src/utils/textUtils'; -import { Dashboard, Filters, SavedQueryObject } from './types'; +import { Dashboard, Filters } from './types'; const createFetchResourceMethod = (method: string) => ( resource: string, From 48556f22a8dc0be85b102320ad54116a6a2d5ebf Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Fri, 11 Jun 2021 16:03:20 -0700 Subject: [PATCH 6/7] Remove stale test --- .../views/CRUD/data/savedquery/SavedQueryList.test.jsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.test.jsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.test.jsx index 38ecb4aadc065..2e3a1045bcfaf 100644 --- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.test.jsx +++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.test.jsx @@ -26,7 +26,6 @@ import { render, screen, cleanup, waitFor } from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; import { QueryParamProvider } from 'use-query-params'; import { act } from 'react-dom/test-utils'; -import { handleBulkSavedQueryExport } from 'src/views/CRUD/utils'; import * as featureFlags from 'src/featureFlags'; import SavedQueryList from 'src/views/CRUD/data/savedquery/SavedQueryList'; import SubMenu from 'src/components/Menu/SubMenu'; @@ -285,14 +284,6 @@ describe('RTL', () => { expect(exportTooltip).toBeInTheDocument(); }); - it('runs handleBulkSavedQueryExport when export is clicked', () => { - // Grab Export action button and mock mouse clicking it - const exportActionButton = screen.getAllByRole('button')[18]; - userEvent.click(exportActionButton); - - expect(handleBulkSavedQueryExport).toHaveBeenCalled(); - }); - it('renders an import button in the submenu', () => { // Grab and assert that import saved query button is visible const importButton = screen.getByTestId('import-button'); From 62285bbb231fc37e9bc1638fbdbdc9a530133e6b Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Fri, 11 Jun 2021 16:48:45 -0700 Subject: [PATCH 7/7] Add explicit type --- superset-frontend/src/utils/export.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/src/utils/export.ts b/superset-frontend/src/utils/export.ts index 45d78ac9712f8..4f3ea75d76751 100644 --- a/superset-frontend/src/utils/export.ts +++ b/superset-frontend/src/utils/export.ts @@ -38,7 +38,7 @@ export default function handleResourceExport( document.body.appendChild(iframe); const timer = window.setInterval(() => { - const cookie = parseCookie(); + const cookie: { [cookieId: string]: string } = parseCookie(); if (cookie[token] === 'done') { window.clearInterval(timer); document.body.removeChild(iframe);