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

allow saving chart images (#28546)

parent 05ca8f07
No related branches found
No related tags found
No related merge requests found
Showing
with 223 additions and 110 deletions
......@@ -15,6 +15,7 @@ export default Object.assign(Action, {
hidden: true,
supportPreviewing: false,
disableSettingsConfig: true,
canSavePng: false,
minSize: { width: 1, height: 1 },
......
/* eslint-disable react/prop-types */
import React from "react";
import PropTypes from "prop-types";
import { t } from "ttag";
import { color } from "metabase/lib/colors";
import { extractQueryParams } from "metabase/lib/urls";
import Icon from "metabase/components/Icon";
import Label from "metabase/components/type/Label";
import { saveChartImage } from "metabase/visualizations/lib/save-chart-image";
import { getCardKey } from "metabase/visualizations/lib/utils";
import { FormButton } from "./DownloadButton.styled";
function colorForType(type) {
......@@ -17,6 +20,8 @@ function colorForType(type) {
return color("summarize");
case "json":
return color("bg-dark");
case "png":
return color("accent0");
default:
return color("brand");
}
......@@ -79,7 +84,34 @@ const handleSubmit = async (
.catch(() => onDownloadRejected());
};
const DownloadButton = ({
export const DownloadButtonBase = ({ format, onClick, ...rest }) => {
return (
<FormButton
className="hover-parent hover--inherit"
onClick={onClick}
{...rest}
>
<Icon name={format} size={32} mr={1} color={colorForType(format)} />
<Label my={0}>.{format}</Label>
</FormButton>
);
};
const getFileName = card =>
`${card.name ?? t`New question`}-${new Date().toLocaleString()}.png`;
export const SaveAsPngButton = ({ card, onSave }) => {
const handleSave = async () => {
const cardNodeSelector = `[data-card-key='${getCardKey(card)}']`;
const name = getFileName(card);
await saveChartImage(cardNodeSelector, name);
onSave?.();
};
return <DownloadButtonBase onClick={handleSave} format="png" />;
};
export const DownloadButton = ({
children,
method,
url,
......@@ -104,8 +136,8 @@ const DownloadButton = ({
}
>
{params && extractQueryParams(params).map(getInput)}
<FormButton
className="hover-parent hover--inherit"
<DownloadButtonBase
format={children}
onClick={e => {
if (window.OSX) {
// prevent form from being submitted normally
......@@ -115,10 +147,7 @@ const DownloadButton = ({
}
}}
{...props}
>
<Icon name={children} size={32} mr={1} color={colorForType(children)} />
<Label my={0}>.{children}</Label>
</FormButton>
/>
</form>
</div>
);
......@@ -142,5 +171,3 @@ DownloadButton.defaultProps = {
params: {},
extensions: [],
};
export default DownloadButton;
......@@ -11,6 +11,7 @@ import WithVizSettingsData from "metabase/visualizations/hoc/WithVizSettingsData
import { getVisualizationRaw } from "metabase/visualizations";
import QueryDownloadWidget from "metabase/query_builder/components/QueryDownloadWidget";
import { SAVING_CHART_IMAGE_HIDDEN_CLASS } from "metabase/visualizations/lib/save-chart-image";
import {
getVirtualCardType,
......@@ -196,6 +197,7 @@ function DashCardVisualization({
return (
<CardDownloadWidget
className={SAVING_CHART_IMAGE_HIDDEN_CLASS}
classNameClose="hover-child hover-child--smooth"
card={dashcard.card}
result={mainSeries}
......
......@@ -143,6 +143,8 @@ export const ICON_PATHS: Record<string, any> = {
curved:
"M3.314 25.007c-.398.852-1.427 1.228-2.298.84a1.68 1.68 0 0 1-.86-2.247c3.754-8.047 7.654-12.229 12.06-12.229 2.93 0 4.406 1.185 6.481 4.098l.098.137c1.413 1.984 2.054 2.507 3.318 2.507 2.293 0 4.562-2.814 6.495-8.918.283-.895 1.254-1.396 2.17-1.119.915.277 1.427 1.227 1.144 2.122-2.337 7.38-5.503 11.307-9.809 11.307-2.765 0-4.15-1.132-6.166-3.961l-.097-.137c-1.479-2.075-2.187-2.644-3.635-2.644-2.58 0-5.667 3.31-8.901 10.244z",
csv: "M28 10.105v18.728A3.166 3.166 0 0 1 24.834 32H6.166A3.163 3.163 0 0 1 3 28.844V3.156A3.163 3.163 0 0 1 6.16 0h13.553V10.105H28zm-.215-1.684h-6.4V.311l6.4 8.11zM17 13v2h2v-2h-2zm0 4v2h2v-2h-2zm4-4v2h2v-2h-2zM7 13v2h7v-2H7zm14 4v2h2v-2h-2zM7 17v2h7v-2H7zm10 4v2h2v-2h-2zm4 0v2h2v-2h-2zM7 21v2h7v-2H7z",
// FIXME: update PNG icon
png: "M28 10.105v18.728A3.166 3.166 0 0 1 24.834 32H6.166A3.163 3.163 0 0 1 3 28.844V3.156A3.163 3.163 0 0 1 6.16 0h13.553V10.105H28zm-.215-1.684h-6.4V.311l6.4 8.11zM17 13v2h2v-2h-2zm0 4v2h2v-2h-2zm4-4v2h2v-2h-2zM7 13v2h7v-2H7zm14 4v2h2v-2h-2zM7 17v2h7v-2H7zm10 4v2h2v-2h-2zm4 0v2h2v-2h-2zM7 21v2h7v-2H7z",
database:
"M0 9.32V4.054S1.584 0 15.657 0C29.731 0 31.89 3.669 31.89 4.054v5.24s-1.445 4.125-15.424 4.125S0 10.138 0 9.32zm.305 12.93s2.044 3.692 15.727 3.692 15.63-3.72 15.63-3.72.338.099.338.632v5S30.463 32 15.964 32C1.465 32 .041 27.817.041 27.817V22.9c0-.582.264-.65.264-.65zm0-9.368s2.044 3.692 15.727 3.692 15.63-3.72 15.63-3.72.338.099.338.632v5.001s-1.537 4.145-16.036 4.145C1.465 22.632.041 18.45.041 18.45v-4.918c0-.583.264-.65.264-.65z",
dash: "M0 13h32v6.61H0z",
......
......@@ -181,6 +181,7 @@ class PublicQuestion extends Component {
uuid={uuid}
token={token}
result={result}
card={card}
/>
);
......
......@@ -8,10 +8,14 @@ import querystring from "querystring";
import _ from "underscore";
import cx from "classnames";
import { canSavePng } from "metabase/visualizations";
import PopoverWithTrigger from "metabase/components/PopoverWithTrigger";
import Icon from "metabase/components/Icon";
import LoadingSpinner from "metabase/components/LoadingSpinner";
import DownloadButton from "metabase/components/DownloadButton";
import {
DownloadButton,
SaveAsPngButton,
} from "metabase/components/DownloadButton";
import Tooltip from "metabase/core/components/Tooltip";
import { PLUGIN_FEATURE_LEVEL_PERMISSIONS } from "metabase/plugins";
......@@ -66,78 +70,83 @@ const QueryDownloadWidget = ({
</WidgetMessage>
)}
<div>
{EXPORT_FORMATS.map(type => (
<WidgetFormat key={type}>
{dashcardId && token ? (
<DashboardEmbedQueryButton
key={type}
type={type}
dashcardId={dashcardId}
token={token}
card={card}
params={params}
onDownloadStart={() => {
setStatus("pending");
closePopover();
}}
onDownloadResolved={() => setStatus("resolved")}
onDownloadRejected={() => setStatus("rejected")}
/>
) : uuid ? (
<PublicQueryButton
key={type}
type={type}
uuid={uuid}
result={result}
onDownloadStart={() => {
setStatus("pending");
closePopover();
}}
onDownloadResolved={() => setStatus("resolved")}
onDownloadRejected={() => setStatus("rejected")}
/>
) : token ? (
<EmbedQueryButton
key={type}
type={type}
token={token}
onDownloadStart={() => {
setStatus("pending");
closePopover();
}}
onDownloadResolved={() => setStatus("resolved")}
onDownloadRejected={() => setStatus("rejected")}
/>
) : card && card.id ? (
<SavedQueryButton
key={type}
type={type}
card={card}
result={result}
disabled={status === "pending"}
onDownloadStart={() => {
setStatus("pending");
closePopover();
}}
onDownloadResolved={() => setStatus("resolved")}
onDownloadRejected={() => setStatus("rejected")}
/>
) : card && !card.id ? (
<UnsavedQueryButton
key={type}
type={type}
result={result}
visualizationSettings={visualizationSettings}
onDownloadStart={() => {
setStatus("pending");
closePopover();
}}
onDownloadResolved={() => setStatus("resolved")}
onDownloadRejected={() => setStatus("rejected")}
/>
) : null}
</WidgetFormat>
))}
<>
{EXPORT_FORMATS.map(type => (
<WidgetFormat key={type}>
{dashcardId && token ? (
<DashboardEmbedQueryButton
key={type}
type={type}
dashcardId={dashcardId}
token={token}
card={card}
params={params}
onDownloadStart={() => {
setStatus("pending");
closePopover();
}}
onDownloadResolved={() => setStatus("resolved")}
onDownloadRejected={() => setStatus("rejected")}
/>
) : uuid ? (
<PublicQueryButton
key={type}
type={type}
uuid={uuid}
result={result}
onDownloadStart={() => {
setStatus("pending");
closePopover();
}}
onDownloadResolved={() => setStatus("resolved")}
onDownloadRejected={() => setStatus("rejected")}
/>
) : token ? (
<EmbedQueryButton
key={type}
type={type}
token={token}
onDownloadStart={() => {
setStatus("pending");
closePopover();
}}
onDownloadResolved={() => setStatus("resolved")}
onDownloadRejected={() => setStatus("rejected")}
/>
) : card && card.id ? (
<SavedQueryButton
key={type}
type={type}
card={card}
result={result}
disabled={status === "pending"}
onDownloadStart={() => {
setStatus("pending");
closePopover();
}}
onDownloadResolved={() => setStatus("resolved")}
onDownloadRejected={() => setStatus("rejected")}
/>
) : card && !card.id ? (
<UnsavedQueryButton
key={type}
type={type}
result={result}
visualizationSettings={visualizationSettings}
onDownloadStart={() => {
setStatus("pending");
closePopover();
}}
onDownloadResolved={() => setStatus("resolved")}
onDownloadRejected={() => setStatus("rejected")}
/>
) : null}
</WidgetFormat>
))}
{canSavePng(card.display) ? (
<SaveAsPngButton card={card} onSave={closePopover} />
) : null}
</>
</div>
</WidgetRoot>
)}
......
......@@ -60,7 +60,7 @@ export default class QueryVisualization extends Component {
} = this.props;
return (
<div className={cx(className, "relative stacking-context")}>
<div className={cx(className, "relative stacking-context full-height")}>
{isRunning ? (
<VisualizationRunningState
className="spread z2"
......
import React from "react";
import { css, Global } from "@emotion/react";
import { FontFile } from "metabase-types/api";
import { saveChartImageStyles } from "metabase/visualizations/lib/save-chart-image";
export interface GlobalStylesProps {
font: string;
......@@ -24,6 +25,8 @@ const GlobalStyles = ({ font, fontFiles }: GlobalStylesProps): JSX.Element => {
}
`,
)}
${saveChartImageStyles}
`;
return <Global styles={styles} />;
......
......@@ -25,7 +25,7 @@ import {
ChartSettingsError,
} from "metabase/visualizations/lib/errors";
import { getComputedSettingsForSeries } from "metabase/visualizations/lib/settings/visualization";
import { isSameSeries } from "metabase/visualizations/lib/utils";
import { isSameSeries, getCardKey } from "metabase/visualizations/lib/utils";
import { getMode } from "metabase/modes/lib/modes";
import { getFont } from "metabase/styled-components/selectors";
......@@ -482,31 +482,36 @@ class Visualization extends React.PureComponent {
) : loading ? (
<LoadingView expectedDuration={expectedDuration} isSlow={isSlow} />
) : (
<CardVisualization
{...this.props}
// NOTE: CardVisualization class used to target ExplicitSize HOC
className="CardVisualization flex-full flex-basis-none"
isPlaceholder={isPlaceholder}
series={series}
settings={settings}
card={series[0].card} // convenience for single-series visualizations
data={series[0].data} // convenience for single-series visualizations
hovered={hovered}
clicked={clicked}
headerIcon={hasHeader ? null : headerIcon}
onHoverChange={this.handleHoverChange}
onVisualizationClick={this.handleVisualizationClick}
visualizationIsClickable={this.visualizationIsClickable}
onRenderError={this.onRenderError}
onRender={this.onRender}
onActionDismissal={this.hideActions}
gridSize={gridSize}
onChangeCardAndRun={
this.props.onChangeCardAndRun
? this.handleOnChangeCardAndRun
: null
}
/>
<div
data-card-key={getCardKey(series[0].card)}
className="flex flex-column full-height"
>
<CardVisualization
{...this.props}
// NOTE: CardVisualization class used to target ExplicitSize HOC
className="CardVisualization flex-full flex-basis-none"
isPlaceholder={isPlaceholder}
series={series}
settings={settings}
card={series[0].card} // convenience for single-series visualizations
data={series[0].data} // convenience for single-series visualizations
hovered={hovered}
clicked={clicked}
headerIcon={hasHeader ? null : headerIcon}
onHoverChange={this.handleHoverChange}
onVisualizationClick={this.handleVisualizationClick}
visualizationIsClickable={this.visualizationIsClickable}
onRenderError={this.onRenderError}
onRender={this.onRender}
onActionDismissal={this.hideActions}
gridSize={gridSize}
onChangeCardAndRun={
this.props.onChangeCardAndRun
? this.handleOnChangeCardAndRun
: null
}
/>
</div>
)}
<ChartTooltip series={series} hovered={hovered} settings={settings} />
{this.props.onChangeCardAndRun && (
......
......@@ -105,6 +105,11 @@ export function getMaxDimensionsSupported(display) {
return visualization.maxDimensionsSupported || 2;
}
export function canSavePng(display) {
const visualization = visualizations.get(display);
return visualization.canSavePng ?? true;
}
// removes columns with `remapped_from` property and adds a `remapping` to the appropriate column
export const extractRemappedColumns = data => {
const cols = data.cols.map(col => ({
......
import { css } from "@emotion/react";
import html2canvas from "html2canvas";
export const SAVING_CHART_IMAGE_CLASS = "saving-chart-image";
export const SAVING_CHART_IMAGE_HIDDEN_CLASS = "saving-chart-image-hidden";
export const saveChartImageStyles = css`
.${SAVING_CHART_IMAGE_CLASS} {
.${SAVING_CHART_IMAGE_HIDDEN_CLASS} {
visibility: hidden;
}
}
`;
export const saveChartImage = async (selector: string, fileName: string) => {
const node = document.querySelector(selector);
if (!node || !(node instanceof HTMLElement)) {
console.warn("No node found for selector", selector);
return;
}
node.classList.add(SAVING_CHART_IMAGE_CLASS);
const canvas = await html2canvas(node);
node.classList.remove(SAVING_CHART_IMAGE_CLASS);
const link = document.createElement("a");
link.setAttribute("download", fileName);
link.setAttribute(
"href",
canvas.toDataURL("image/png").replace("image/png", "image/octet-stream"),
);
link.click();
link.remove();
};
......@@ -390,3 +390,7 @@ export const preserveExistingColumnsOrder = (prevColumns, newColumns) => {
return mergedColumnsResult;
};
export function getCardKey(card) {
return `${card?.id ?? "unsaved"}`;
}
......@@ -2,6 +2,7 @@ import { t } from "ttag";
export const settings = {
uiName: "Link",
canSavePng: false,
identifier: "link",
iconName: "link",
disableSettingsConfig: true,
......
......@@ -22,6 +22,7 @@ const ObjectDetailProperties = {
iconName: "document",
noun: t`object`,
hidden: false,
canSavePng: false,
disableClickBehavior: true,
settings: {
...columnSettings({ hidden: true }),
......
......@@ -488,6 +488,7 @@ export default Object.assign(connect(mapStateToProps)(PivotTable), {
uiName: t`Pivot Table`,
identifier: "pivot",
iconName: "pivot_table",
canSavePng: false,
databaseSupportsPivotTables,
isSensible,
checkRenderable,
......
......@@ -36,6 +36,7 @@ export default class Scalar extends Component {
static uiName = t`Number`;
static identifier = "scalar";
static iconName = "number";
static canSavePng = false;
static noHeader = true;
static supportsSeries = true;
......
......@@ -29,6 +29,7 @@ export default class Smart extends React.Component {
static uiName = t`Trend`;
static identifier = "smartscalar";
static iconName = "smartscalar";
static canSavePng = false;
static minSize = { width: 3, height: 3 };
......
......@@ -47,6 +47,7 @@ export default class Table extends Component {
static uiName = t`Table`;
static identifier = "table";
static iconName = "table";
static canSavePng = false;
static minSize = { width: 4, height: 3 };
......
......@@ -37,6 +37,7 @@ export default class Text extends Component {
static uiName = "Text";
static identifier = "text";
static iconName = "text";
static canSavePng = false;
static disableSettingsConfig = false;
static noHeader = true;
......
import * as dbTasks from "./db_tasks";
const { verifyDownloadTasks } = require("cy-verify-downloads");
const {
NodeModulesPolyfillPlugin,
} = require("@esbuild-plugins/node-modules-polyfill");
/**
* This env var provides the token to the backend.
......@@ -39,7 +43,10 @@ const defaultConfig = {
** PREPROCESSOR **
********************************************************************/
on("file:preprocessor", createBundler());
on(
"file:preprocessor",
createBundler({ plugins: [NodeModulesPolyfillPlugin()] }),
);
/********************************************************************
** BROWSERS **
......@@ -68,6 +75,7 @@ const defaultConfig = {
********************************************************************/
on("task", {
...dbTasks,
...verifyDownloadTasks,
});
/********************************************************************
......
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