feat: show spinner on exports (#15107)

* feat: show spinner on exports

* Set cookie only if token is passed

* Use iframe

* Small fixes

* Fix lint

* Remove stale test

* Add explicit type
This commit is contained in:
Beto Dealmeida
2021-06-11 17:25:00 -07:00
committed by GitHub
parent ff2d5888d9
commit 53df152362
17 changed files with 167 additions and 60 deletions

View File

@@ -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: { [cookieId: string]: string } = parseCookie();
if (cookie[token] === 'done') {
window.clearInterval(timer);
document.body.removeChild(iframe);
done();
}
}, interval);
}

View File

@@ -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');

View File

@@ -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<boolean>(false);
const [passwordFields, setPasswordFields] = useState<string[]>([]);
const [preparingExport, setPreparingExport] = useState<boolean>(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 && <Loading />}
</>
);
}

View File

@@ -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');

View File

@@ -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<boolean>(false);
const [passwordFields, setPasswordFields] = useState<string[]>([]);
const [preparingExport, setPreparingExport] = useState<boolean>(false);
const openDashboardImportModal = () => {
showImportModal(true);
@@ -170,6 +172,14 @@ function DashboardList(props: DashboardListProps) {
);
}
const handleBulkDashboardExport = (dashboardsToExport: Dashboard[]) => {
const ids = dashboardsToExport.map(({ id }) => id);
handleResourceExport('dashboard', 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 && <Loading />}
</>
);
}

View File

@@ -18,7 +18,7 @@
*/
import { SupersetClient, t, styled } from '@superset-ui/core';
import React, { useState, useMemo } from 'react';
import rison from 'rison';
import Loading from 'src/components/Loading';
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
import { useListViewResource } from 'src/views/CRUD/hooks';
import { createErrorHandler } from 'src/views/CRUD/utils';
@@ -30,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';
@@ -97,6 +98,7 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
);
const [importingDatabase, showImportModal] = useState<boolean>(false);
const [passwordFields, setPasswordFields] = useState<string[]>([]);
const [preparingExport, setPreparingExport] = useState<boolean>(false);
const openDatabaseImportModal = () => {
showImportModal(true);
@@ -203,9 +205,14 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
}
function handleDatabaseExport(database: DatabaseObject) {
return window.location.assign(
`/api/v1/database/export/?q=${rison.encode([database.id])}`,
);
if (database.id === undefined) {
return;
}
handleResourceExport('database', [database.id], () => {
setPreparingExport(false);
});
setPreparingExport(true);
}
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
@@ -469,6 +476,7 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
passwordFields={passwordFields}
setPasswordFields={setPasswordFields}
/>
{preparingExport && <Loading />}
</>
);
}

View File

@@ -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<DatasetListProps> = ({
const [importingDataset, showImportModal] = useState<boolean>(false);
const [passwordFields, setPasswordFields] = useState<string[]>([]);
const [preparingExport, setPreparingExport] = useState<boolean>(false);
const openDatasetImportModal = () => {
showImportModal(true);
@@ -547,12 +550,13 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
);
};
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<DatasetListProps> = ({
passwordFields={passwordFields}
setPasswordFields={setPasswordFields}
/>
{preparingExport && <Loading />}
</>
);
};

View File

@@ -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');

View File

@@ -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<SavedQueryObject | null>(null);
const [importingSavedQuery, showImportModal] = useState<boolean>(false);
const [passwordFields, setPasswordFields] = useState<string[]>([]);
const [preparingExport, setPreparingExport] = useState<boolean>(false);
const openSavedQueryImportModal = () => {
showImportModal(true);
@@ -238,6 +240,16 @@ function SavedQueryList({
);
};
const handleBulkSavedQueryExport = (
savedQueriesToExport: SavedQueryObject[],
) => {
const ids = savedQueriesToExport.map(({ id }) => id);
handleResourceExport('saved_query', 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 && <Loading />}
</>
);
}

View File

@@ -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,
@@ -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,

View File

@@ -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<boolean>(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}
/>
))}
</CardContainer>
) : (
<EmptyState tableName="CHARTS" tab={chartFilter} />
)}
{preparingExport && <Loading />}
</ErrorBoundary>
);
}

View File

@@ -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<Dashboard>();
const [dashboardFilter, setDashboardFilter] = useState('Mine');
const [preparingExport, setPreparingExport] = useState<boolean>(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('dashboard', 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}
/>
))}
</CardContainer>
@@ -228,6 +239,7 @@ function DashboardTable({
{dashboards.length === 0 && (
<EmptyState tableName="DASHBOARDS" tab={dashboardFilter} />
)}
{preparingExport && <Loading />}
</>
);
}

View File

@@ -903,6 +903,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
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 @@ class ChartRestApi(BaseSupersetModelRestApi):
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()

View File

@@ -706,6 +706,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
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 @@ class DashboardRestApi(BaseSupersetModelRestApi):
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)

View File

@@ -722,6 +722,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
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,15 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
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()

View File

@@ -432,6 +432,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
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 @@ class DatasetRestApi(BaseSupersetModelRestApi):
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)

View File

@@ -237,6 +237,7 @@ class SavedQueryRestApi(BaseSupersetModelRestApi):
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 @@ class SavedQueryRestApi(BaseSupersetModelRestApi):
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()