Skip to content
Snippets Groups Projects
Unverified Commit da6fe862 authored by Aleksandr Lesnenko's avatar Aleksandr Lesnenko Committed by GitHub
Browse files

Indicate in-progress exports (#45697)

* indicate in-progress exports

* specs, public dashboards

* add onbeforeunload hook when uploads are in-progress
parent 17f64439
No related branches found
No related tags found
No related merge requests found
Showing
with 156 additions and 70 deletions
......@@ -166,3 +166,14 @@ function getEndpoint({
method: "POST",
};
}
export function dismissDownloadStatus() {
cy.findByTestId("status-root-container").within(() => {
cy.findByRole("status").within(() => {
cy.findAllByText("Download completed");
cy.findByLabelText("Dismiss").click();
});
cy.findByRole("status").should("not.exist");
});
}
......@@ -27,6 +27,7 @@ import {
setEmbeddingParameter,
assertEmbeddingParameter,
multiAutocompleteInput,
dismissDownloadStatus,
} from "e2e/support/helpers";
import { createMockParameter } from "metabase-types/api/mocks";
......@@ -526,6 +527,7 @@ describe("scenarios > embedding > dashboard parameters", () => {
assertSheetRowsCount(54)(sheet);
},
);
dismissDownloadStatus();
});
});
......
......@@ -30,6 +30,7 @@ import {
showDashboardCardActions,
getDashboardCard,
multiAutocompleteInput,
dismissDownloadStatus,
} from "e2e/support/helpers";
const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE;
......@@ -81,6 +82,8 @@ describe("scenarios > question > download", () => {
expect(sheet["A1"].v).to.eq("Count");
expect(sheet["A2"].v).to.eq(18760);
});
dismissDownloadStatus();
});
});
......@@ -124,6 +127,8 @@ describe("scenarios > question > download", () => {
},
);
dismissDownloadStatus();
downloadAndAssert(
{
...opts,
......@@ -148,6 +153,8 @@ describe("scenarios > question > download", () => {
assertOrdersExport(18760);
dismissDownloadStatus();
editDashboard();
setFilter("ID");
......@@ -171,6 +178,8 @@ describe("scenarios > question > download", () => {
});
assertOrdersExport(1);
dismissDownloadStatus();
});
it("should allow downloading parameterized cards opened from dashboards as a user with no self-service permission (metabase#20868)", () => {
......@@ -244,6 +253,8 @@ describe("scenarios > question > download", () => {
assertSheetRowsCount(1)(sheet);
},
);
dismissDownloadStatus();
});
});
});
......
......@@ -10,6 +10,7 @@ import {
createPublicQuestionLink,
modal,
openNativeEditor,
dismissDownloadStatus,
} from "e2e/support/helpers";
const { PEOPLE } = SAMPLE_DATABASE;
......@@ -91,6 +92,7 @@ describe("scenarios > public > question", () => {
{ fileType: "xlsx", questionId: id, publicUuid },
assertSheetRowsCount(5),
);
dismissDownloadStatus();
});
});
});
......
import type { ScheduleSettings } from "./settings";
import type { Table } from "./table";
import type { ISO8601Time } from ".";
import type { ISO8601Time, LongTaskStatus } from ".";
export type DatabaseId = number;
export type InitialSyncStatus = "incomplete" | "complete" | "aborted";
export type InitialSyncStatus = LongTaskStatus;
export type DatabaseSettings = {
[key: string]: any;
......
......@@ -41,3 +41,5 @@ export interface BugReportDetails {
"metabase-info": MetabaseInfo;
"system-info": SystemInfo;
}
export type LongTaskStatus = "incomplete" | "complete" | "aborted";
export interface Download {
id: number;
title: string;
status: "complete" | "in-progress" | "error";
error?: string;
}
export type DownloadsState = Download[];
......@@ -11,3 +11,4 @@ export * from "./requests";
export * from "./settings";
export * from "./setup";
export * from "./state";
export * from "./downloads";
import type { Download } from "../downloads";
export const createMockDownload = (props: Partial<Download> = {}): Download => {
return {
id: Date.now(),
title: "file.csv",
status: "in-progress",
...props,
};
};
......@@ -12,3 +12,4 @@ export * from "./settings";
export * from "./setup";
export * from "./state";
export * from "./upload";
export * from "./downloads";
......@@ -6,6 +6,7 @@ import type { AdminState } from "./admin";
import type { AppState } from "./app";
import type { AuthState } from "./auth";
import type { DashboardState } from "./dashboard";
import type { DownloadsState } from "./downloads";
import type { EmbedState } from "./embed";
import type { EntitiesState } from "./entities";
import type { MetabotState } from "./metabot";
......@@ -37,6 +38,7 @@ export interface State {
upload: FileUploadState;
modal: ModalName;
undo: UndoState;
downloads: DownloadsState;
}
export type Dispatch<T = any> = (action: T) => unknown | Promise<unknown>;
......
......@@ -567,3 +567,15 @@ export function reload() {
export function redirect(url) {
window.location.href = url;
}
export function openSaveDialog(fileName, fileContent) {
const url = URL.createObjectURL(fileContent);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", fileName);
document.body.appendChild(link);
link.click();
URL.revokeObjectURL(url);
link.remove();
}
......@@ -3,6 +3,7 @@ import { connect } from "react-redux";
import { PublicError } from "metabase/public/components/PublicError";
import { PublicNotFound } from "metabase/public/components/PublicNotFound";
import { getErrorPage } from "metabase/selectors/app";
import { PublicStatusListing } from "metabase/status/components/PublicStatusListing";
import type { AppErrorDescriptor, State } from "metabase-types/store";
interface OwnProps {
......@@ -25,7 +26,12 @@ function PublicApp({ errorPage, children }: Props) {
if (errorPage) {
return errorPage.status === 404 ? <PublicNotFound /> : <PublicError />;
}
return children;
return (
<>
{children}
<PublicStatusListing />
</>
);
}
// eslint-disable-next-line import/no-default-export -- deprecated usage
......
export * from "./core";
export * from "./downloading";
export * from "./models";
export * from "./native";
export * from "./navigation";
......
......@@ -2,7 +2,7 @@ import { useAsyncFn } from "react-use";
import type { AsyncFnReturn } from "react-use/lib/useAsyncFn";
import { useDispatch } from "metabase/lib/redux";
import { downloadQueryResults } from "metabase/query_builder/actions";
import { downloadQueryResults } from "metabase/redux/downloads";
import type Question from "metabase-lib/v1/Question";
import type {
DashboardId,
......
import { useState } from "react";
import { t } from "ttag";
import LoadingSpinner from "metabase/components/LoadingSpinner";
import { PLUGIN_FEATURE_LEVEL_PERMISSIONS } from "metabase/plugins";
import { Flex, Popover, Tooltip } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
......@@ -40,7 +39,7 @@ const QueryDownloadWidget = ({
}: QueryDownloadWidgetProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [{ loading }, handleDownload] = useDownloadData({
const [, handleDownload] = useDownloadData({
question,
result,
dashboardId,
......@@ -54,20 +53,14 @@ const QueryDownloadWidget = ({
<Popover opened={isPopoverOpen} onClose={() => setIsPopoverOpen(false)}>
<Popover.Target>
<Flex className={className}>
{loading ? (
<Tooltip label={t`Downloading…`}>
<LoadingSpinner size={18} />
</Tooltip>
) : (
<Tooltip label={t`Download full results`}>
<DownloadIcon
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
name="download"
size={20}
data-testid="download-button"
/>
</Tooltip>
)}
<Tooltip label={t`Download full results`}>
<DownloadIcon
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
name="download"
size={20}
data-testid="download-button"
/>
</Tooltip>
</Flex>
</Popover.Target>
<Popover.Dropdown p="0.75rem">
......
......@@ -7,6 +7,7 @@ import { dashboardReducers as dashboard } from "metabase/dashboard/reducers";
import * as parameters from "metabase/parameters/reducers";
import app from "metabase/redux/app";
import { reducer as auth } from "metabase/redux/auth";
import { reducer as downloads } from "metabase/redux/downloads";
import embed from "metabase/redux/embed";
import entities, { enhanceRequestsReducer } from "metabase/redux/entities";
import requests from "metabase/redux/requests";
......@@ -32,4 +33,5 @@ export const commonReducers = {
modal,
dashboard,
parameters: combineReducers(parameters),
downloads,
};
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { t } from "ttag";
import _ from "underscore";
import api, { GET, POST } from "metabase/lib/api";
import { openSaveDialog } from "metabase/lib/dom";
import { checkNotNull } from "metabase/lib/types";
import * as Urls from "metabase/lib/urls";
import { saveChartImage } from "metabase/visualizations/lib/save-chart-image";
......@@ -13,6 +15,7 @@ import type {
Dataset,
VisualizationSettings,
} from "metabase-types/api";
import type { DownloadsState, State } from "metabase-types/store";
export interface DownloadQueryResultsOpts {
type: string;
......@@ -34,14 +37,16 @@ interface DownloadQueryResultsParams {
params?: URLSearchParams | string;
}
export const downloadQueryResults =
(opts: DownloadQueryResultsOpts) => async () => {
export const downloadQueryResults = createAsyncThunk(
"metabase/downloads/downloadQueryResults",
async (opts: DownloadQueryResultsOpts, { dispatch }) => {
if (opts.type === Urls.exportFormatPng) {
await downloadChart(opts);
downloadChart(opts);
} else {
await downloadDataset(opts);
dispatch(downloadDataset({ opts, id: Date.now() }));
}
};
},
);
const downloadChart = async ({
question,
......@@ -55,13 +60,18 @@ const downloadChart = async ({
await saveChartImage(chartSelector, fileName);
};
const downloadDataset = async (opts: DownloadQueryResultsOpts) => {
const params = getDatasetParams(opts);
const response = await getDatasetResponse(params);
const fileName = getDatasetFileName(response.headers, opts.type);
const fileContent = await response.blob();
openSaveDialog(fileName, fileContent);
};
export const downloadDataset = createAsyncThunk(
"metabase/downloads/downloadDataset",
async ({ opts, id }: { opts: DownloadQueryResultsOpts; id: number }) => {
const params = getDatasetParams(opts);
const response = await getDatasetResponse(params);
const name = getDatasetFileName(response.headers, opts.type);
const fileContent = await response.blob();
openSaveDialog(name, fileContent);
return { id, name };
},
);
const getDatasetParams = ({
type,
......@@ -230,14 +240,49 @@ const getChartFileName = (question: Question) => {
return `${name}-${date}.png`;
};
const openSaveDialog = (fileName: string, fileContent: Blob) => {
const url = URL.createObjectURL(fileContent);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", fileName);
document.body.appendChild(link);
link.click();
export const getDownloads = (state: State) => state.downloads;
export const hasActiveDownloads = (state: State) =>
state.downloads.some(download => download.status === "in-progress");
URL.revokeObjectURL(url);
link.remove();
};
const initialState: DownloadsState = [];
const downloads = createSlice({
name: "metabase/downloads",
initialState,
reducers: {
clearAll: () => initialState,
},
extraReducers: builder => {
builder
.addCase(downloadDataset.pending, (state, action) => {
const title = t`Results for ${
action.meta.arg.opts.question.card().name
}`;
state.push({
id: action.meta.arg.id,
title,
status: "in-progress",
});
})
.addCase(downloadDataset.fulfilled, (state, action) => {
const download = state.find(item => item.id === action.meta.arg.id);
if (download) {
download.status = "complete";
download.title = action.payload.name;
}
})
.addCase(downloadDataset.rejected, (state, action) => {
const download = state.find(item => item.id === action.meta.arg.id);
if (download) {
download.status = "error";
download.error =
action.error.message ?? t`Could not download the file`;
}
});
},
});
export const {
actions: { clearAll },
} = downloads;
export const { reducer } = downloads;
import api from "metabase/lib/api";
import * as downloading from "./downloading";
import * as downloads from "./downloads";
describe("getDatasetResponse", () => {
describe("normal deployment", () => {
......@@ -9,7 +9,7 @@ describe("getDatasetResponse", () => {
it("should handle absolute URLs", () => {
const url = `${origin}/embed/question/123.xlsx`;
expect(downloading.getDatasetDownloadUrl(url)).toBe(
expect(downloads.getDatasetDownloadUrl(url)).toBe(
`${origin}/embed/question/123.xlsx`,
);
});
......@@ -17,7 +17,7 @@ describe("getDatasetResponse", () => {
it("should handle relative URLs", () => {
const url = "/embed/question/123.xlsx";
expect(downloading.getDatasetDownloadUrl(url)).toBe(
expect(downloads.getDatasetDownloadUrl(url)).toBe(
`/embed/question/123.xlsx`,
);
});
......@@ -44,7 +44,7 @@ describe("getDatasetResponse", () => {
it("should handle absolute URLs", () => {
const url = `${origin}${subpath}/embed/question/123.xlsx`;
expect(downloading.getDatasetDownloadUrl(url)).toBe(
expect(downloads.getDatasetDownloadUrl(url)).toBe(
`/embed/question/123.xlsx`,
);
});
......@@ -52,7 +52,7 @@ describe("getDatasetResponse", () => {
it("should handle relative URLs", () => {
const url = "/embed/question/123.xlsx";
expect(downloading.getDatasetDownloadUrl(url)).toBe(
expect(downloads.getDatasetDownloadUrl(url)).toBe(
`/embed/question/123.xlsx`,
);
});
......
import { t } from "ttag";
import { isReducedMotionPreferred } from "metabase/lib/dom";
import { isSyncAborted, isSyncInProgress } from "metabase/lib/syncing";
import type { IconName } from "metabase/ui";
import type Database from "metabase-lib/v1/metadata/Database";
import type { InitialSyncStatus } from "metabase-types/api";
import StatusSmall from "../StatusSmall";
import { getIconName, isSpinnerVisible } from "../utils/status";
export interface DatabaseStatusSmallProps {
databases: Database[];
......@@ -54,25 +53,5 @@ const getStatusLabel = (status: InitialSyncStatus): string => {
}
};
const getIconName = (status: InitialSyncStatus): IconName => {
switch (status) {
case "incomplete":
return "database";
case "complete":
return "check";
case "aborted":
return "warning";
}
};
const isSpinnerVisible = (status: InitialSyncStatus): boolean => {
switch (status) {
case "incomplete":
return !isReducedMotionPreferred();
default:
return false;
}
};
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default DatabaseStatusSmall;
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment