Skip to content
Snippets Groups Projects
Commit 7ac698b9 authored by Tom Robinson's avatar Tom Robinson
Browse files

JSON download button

parent 9dcab5cd
No related branches found
No related tags found
No related merge requests found
import React, { Component, PropTypes } from "react";
import Icon from "metabase/components/Icon.jsx";
import cx from "classnames";
import _ from "underscore";
const BUTTON_VARIANTS = [
"small",
"medium",
"large",
"primary",
"warning",
"cancel",
"purple",
"borderless"
];
const Button = ({ className, icon, children, ...props }) => {
let variantClasses = BUTTON_VARIANTS.filter(variant => props[variant]).map(variant => "Button--" + variant);
return (
<button
{..._.omit(props, ...variantClasses)}
className={cx("Button", className, variantClasses)}
>
<div className="flex layout-centered">
{ icon && <Icon name={icon} size={14} className="mr1" />}
<div>{children}</div>
</div>
</button>
);
}
export default Button;
import React, { Component, PropTypes } from "react";
import ReactDOM from "react-dom";
import Button from "metabase/components/Button.jsx";
export default class DownloadButton extends Component {
constructor(props, context) {
super(props, context);
this.state = {};
}
static propTypes = {
url: PropTypes.string.isRequired,
method: PropTypes.string,
params: PropTypes.object,
icon: PropTypes.string,
};
static defaultProps = {
icon: "downarrow",
method: "POST",
params: {}
};
render() {
const { children, url, method, params, ...props } = this.props;
return (
<form ref={(c) => this._form = c} method={method} action={url}>
{ Object.entries(params).map(([name, value]) =>
<input key={name} type="hidden" name={name} value={value} />
)}
<Button
onClick={() => ReactDOM.findDOMNode(this._form).submit()}
{...props}
>
{children}
</Button>
</form>
);
}
}
......@@ -56,6 +56,7 @@ export var ICON_PATHS = {
dashboard: 'M32,29 L32,4 L32,0 L0,0 L0,8 L28,8 L28,28 L4,28 L4,8 L0,8 L0,29.5 L0,32 L32,32 L32,29 Z M7.27272727,18.9090909 L17.4545455,18.9090909 L17.4545455,23.2727273 L7.27272727,23.2727273 L7.27272727,18.9090909 Z M7.27272727,12.0909091 L24.7272727,12.0909091 L24.7272727,16.4545455 L7.27272727,16.4545455 L7.27272727,12.0909091 Z M20.3636364,18.9090909 L24.7272727,18.9090909 L24.7272727,23.2727273 L20.3636364,23.2727273 L20.3636364,18.9090909 Z',
dashboards: 'M17,5.49100518 L17,10.5089948 C17,10.7801695 17.2276528,11 17.5096495,11 L26.4903505,11 C26.7718221,11 27,10.7721195 27,10.5089948 L27,5.49100518 C27,5.21983051 26.7723472,5 26.4903505,5 L17.5096495,5 C17.2281779,5 17,5.22788048 17,5.49100518 Z M18.5017326,14 C18.225722,14 18,13.77328 18,13.4982674 L18,26.5017326 C18,26.225722 18.22672,26 18.5017326,26 L5.49826741,26 C5.77427798,26 6,26.22672 6,26.5017326 L6,13.4982674 C6,13.774278 5.77327997,14 5.49826741,14 L18.5017326,14 Z M14.4903505,6 C14.2278953,6 14,5.78028538 14,5.49100518 L14,10.5089948 C14,10.2167107 14.2224208,10 14.4903505,10 L5.50964952,10 C5.77210473,10 6,10.2197146 6,10.5089948 L6,5.49100518 C6,5.78328929 5.77757924,6 5.50964952,6 L14.4903505,6 Z M26.5089948,22 C26.2251201,22 26,21.7774008 26,21.4910052 L26,26.5089948 C26,26.2251201 26.2225992,26 26.5089948,26 L21.4910052,26 C21.7748799,26 22,26.2225992 22,26.5089948 L22,21.4910052 C22,21.7748799 21.7774008,22 21.4910052,22 L26.5089948,22 Z M26.5089948,14 C26.2251201,14 26,13.7774008 26,13.4910052 L26,18.5089948 C26,18.2251201 26.2225992,18 26.5089948,18 L21.4910052,18 C21.7748799,18 22,18.2225992 22,18.5089948 L22,13.4910052 C22,13.7748799 21.7774008,14 21.4910052,14 L26.5089948,14 Z M26.4903505,6 C26.2278953,6 26,5.78028538 26,5.49100518 L26,10.5089948 C26,10.2167107 26.2224208,10 26.4903505,10 L17.5096495,10 C17.7721047,10 18,10.2197146 18,10.5089948 L18,5.49100518 C18,5.78328929 17.7775792,6 17.5096495,6 L26.4903505,6 Z M5,13.4982674 L5,26.5017326 C5,26.7769181 5.21990657,27 5.49826741,27 L18.5017326,27 C18.7769181,27 19,26.7800934 19,26.5017326 L19,13.4982674 C19,13.2230819 18.7800934,13 18.5017326,13 L5.49826741,13 C5.22308192,13 5,13.2199066 5,13.4982674 Z M5,5.49100518 L5,10.5089948 C5,10.7801695 5.22765279,11 5.50964952,11 L14.4903505,11 C14.7718221,11 15,10.7721195 15,10.5089948 L15,5.49100518 C15,5.21983051 14.7723472,5 14.4903505,5 L5.50964952,5 C5.22817786,5 5,5.22788048 5,5.49100518 Z M21,21.4910052 L21,26.5089948 C21,26.7801695 21.2278805,27 21.4910052,27 L26.5089948,27 C26.7801695,27 27,26.7721195 27,26.5089948 L27,21.4910052 C27,21.2198305 26.7721195,21 26.5089948,21 L21.4910052,21 C21.2198305,21 21,21.2278805 21,21.4910052 Z M21,13.4910052 L21,18.5089948 C21,18.7801695 21.2278805,19 21.4910052,19 L26.5089948,19 C26.7801695,19 27,18.7721195 27,18.5089948 L27,13.4910052 C27,13.2198305 26.7721195,13 26.5089948,13 L21.4910052,13 C21.2198305,13 21,13.2278805 21,13.4910052 Z',
document: 'M29,10.1052632 L29,28.8325291 C29,30.581875 27.5842615,32 25.8337327,32 L7.16626728,32 C5.41758615,32 4,30.5837102 4,28.8441405 L4,3.15585953 C4,1.41292644 5.42339685,9.39605581e-15 7.15970573,8.42009882e-15 L20.713352,8.01767853e-16 L20.713352,8.42105263 L22.3846872,8.42105263 L22.3846872,0.310375032 L28.7849894,8.42105263 L20.713352,8.42105263 L20.713352,10.1052632 L29,10.1052632 Z M7.3426704,12.8000006 L25.7273576,12.8000006 L25.7273576,14.4842112 L7.3426704,14.4842112 L7.3426704,12.8000006 Z M7.3426704,17.3473687 L25.7273576,17.3473687 L25.7273576,19.0315793 L7.3426704,19.0315793 L7.3426704,17.3473687 Z M7.3426704,21.8947352 L25.7273576,21.8947352 L25.7273576,23.5789458 L7.3426704,23.5789458 L7.3426704,21.8947352 Z M7.43137255,26.2736849 L16.535014,26.2736849 L16.535014,27.9578954 L7.43137255,27.9578954 L7.43137255,26.2736849 Z',
downarrow: 'M12.2782161,19.3207547 L12.2782161,0 L19.5564322,0 L19.5564322,19.3207547 L26.8346484,19.3207547 L15.9173242,32 L5,19.3207547 L12.2782161,19.3207547 Z',
download: {
path: 'M4,8 L4,0 L7,0 L7,8 L10,8 L5.5,13.25 L1,8 L4,8 Z M11,14 L0,14 L0,17 L11,17 L11,14 Z',
attrs: { viewBox: '0 0 11 17' }
......
import React, { Component, PropTypes } from "react";
import ReactDOM from "react-dom";
import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx";
import Icon from "metabase/components/Icon.jsx";
import DownloadButton from "metabase/components/DownloadButton.jsx";
import FieldSet from "metabase/components/FieldSet.jsx";
export default class DownloadWidget extends Component {
constructor(props, context) {
super(props, context);
this.state = {};
}
static propTypes = {};
static defaultProps = {};
render() {
const { className, card, datasetQuery, isLarge } = this.props;
return (
<PopoverWithTrigger
triggerElement={<Icon className={className} title="Download this data" name='download' size={16} />}
>
<div className="p2" style={{ maxWidth: 300 }}>
<h4>Download</h4>
{isLarge &&
<FieldSet className="my2 text-gold border-gold" legend="Warning">
<div className="my1">Your answer has a large number of rows so it could take awhile to download.</div>
<div>The maximum download size is 1 million rows.</div>
</FieldSet>
}
<div className="flex flex-row mt2">
{["csv", "json"].map(type =>
<DownloadButton
className="mr1 text-uppercase text-default"
url={card.id != null ?
`/api/card/${card.id}/query/${type}`:
`/api/dataset/${type}`
}
params={{
query: JSON.stringify(datasetQuery)
}}
>
{type}
</DownloadButton>
)}
</div>
</div>
</PopoverWithTrigger>
);
}
}
......@@ -2,15 +2,13 @@ import React, { Component, PropTypes } from "react";
import ReactDOM from "react-dom";
import { Link } from "react-router";
import Icon from "metabase/components/Icon.jsx";
import LoadingSpinner from 'metabase/components/LoadingSpinner.jsx';
import RunButton from './RunButton.jsx';
import VisualizationSettings from './VisualizationSettings.jsx';
import VisualizationError from "./VisualizationError.jsx";
import VisualizationResult from "./VisualizationResult.jsx";
import ModalWithTrigger from "metabase/components/ModalWithTrigger.jsx";
import DownloadWidget from "./DownloadWidget.jsx";
import cx from "classnames";
import _ from "underscore";
......@@ -80,7 +78,7 @@ export default class QueryVisualization extends Component {
}
renderHeader() {
const { isObjectDetail, isRunning } = this.props;
const { isObjectDetail, isRunning, card, result } = this.props;
return (
<div className="relative flex flex-no-shrink mt3 mb1" style={{ minHeight: "2em" }}>
<span className="relative z4">
......@@ -96,8 +94,15 @@ export default class QueryVisualization extends Component {
/>
</div>
<div className="absolute right z4 flex align-center">
{!this.queryIsDirty() && this.renderCount()}
{this.renderDownloadButton()}
{ !this.queryIsDirty() && this.renderCount() }
{ !this.queryIsDirty() && result && !result.error ?
<DownloadWidget
className="mx1"
card={card}
datasetQuery={result.json_query}
isLarge={result.data.rows_truncated != null}
/>
: null }
</div>
</div>
);
......@@ -131,81 +136,6 @@ export default class QueryVisualization extends Component {
}
}
onDownloadCSV() {
const form = ReactDOM.findDOMNode(this._downloadCsvForm);
form.query.value = JSON.stringify(this.props.fullDatasetQuery);
form.submit();
}
renderDownloadButton() {
const { card, result } = this.props;
const csvUrl = card.id != null ? `/api/card/${card.id}/query/csv`: "/api/dataset/csv";
if (result && !result.error) {
if (result && result.data && result.data.rows_truncated) {
// this is a "large" dataset, so show a modal to inform users about this and make them click again to d/l
let downloadButton;
if (window.OSX) {
downloadButton = (<button className="Button Button--primary" onClick={() => {
window.OSX.saveCSV(JSON.stringify(card.dataset_query));
this.refs.downloadModal.toggle()
}}>Download CSV</button>);
} else {
downloadButton = (
<form ref={(c) => this._downloadCsvForm = c} method="POST" action={csvUrl}>
<input type="hidden" name="query" value="" />
<a className="Button Button--primary" onClick={() => {this.onDownloadCSV(); this.refs.downloadModal.toggle();}}>
Download CSV
</a>
</form>
);
}
return (
<ModalWithTrigger
key="download"
ref="downloadModal"
className="Modal Modal--small"
triggerElement={<Icon className="mx1" title="Download this data" name='download' size={16} />}
>
<div style={{width: "480px"}} className="Modal--small p4 text-centered relative">
<span className="absolute top right p4 text-normal text-grey-3 cursor-pointer" onClick={() => this.refs.downloadModal.toggle()}>
<Icon name={'close'} size={16} />
</span>
<div className="p3 text-strong">
<h2 className="text-bold">Download large data set</h2>
<div className="pt2">Your answer has a large amount of data so we wanted to let you know it could take a while to download.</div>
<div className="py4">The maximum download amount is 1 million rows.</div>
{downloadButton}
</div>
</div>
</ModalWithTrigger>
);
} else {
if (window.OSX) {
return (
<a className="mx1" title="Download this data" onClick={function() {
window.OSX.saveCSV(JSON.stringify(card.dataset_query));
}}>
<Icon name='download' size={16} />
</a>
);
} else {
return (
<form ref={(c) => this._downloadCsvForm = c} method="POST" action={csvUrl}>
<input type="hidden" name="query" value="" />
<a className="mx1" title="Download this data" onClick={() => this.onDownloadCSV()}>
<Icon name='download' size={16} />
</a>
</form>
);
}
}
}
}
render() {
const { card, databases, isObjectDetail, isRunning, result } = this.props
let viz;
......
......@@ -35,7 +35,6 @@ import {
getParameters,
getDatabaseFields,
getSampleDatasetId,
getFullDatasetQuery,
getNativeDatabases,
getIsRunnable,
} from "../selectors";
......@@ -89,7 +88,6 @@ const mapStateToProps = (state, props) => {
parameters: getParameters(state),
databaseFields: getDatabaseFields(state),
sampleDatasetId: getSampleDatasetId(state),
fullDatasetQuery: getFullDatasetQuery(state),
isShowingDataReference: state.qb.uiControls.isShowingDataReference,
isShowingTutorial: state.qb.uiControls.isShowingTutorial,
......
......@@ -7,7 +7,6 @@ import { getTemplateTags } from "metabase/meta/Card";
import { isCardDirty, isCardRunnable } from "metabase/lib/card";
import { parseFieldTarget } from "metabase/lib/query_time";
import { isPK } from "metabase/lib/types";
import { applyParameters } from "metabase/meta/Card";
export const uiControls = state => state.qb.uiControls;
......@@ -153,12 +152,6 @@ export const getParameters = createSelector(
(implicitParameters) => implicitParameters
);
export const getFullDatasetQuery = createSelector(
[card, getParameters, parameterValues],
(card, parameters, parameterValues) =>
card && applyParameters(card, parameters, parameterValues)
)
export const getIsRunnable = createSelector(
[card, tableMetadata],
(card, tableMetadata) => isCardRunnable(card, tableMetadata)
......
......@@ -277,15 +277,13 @@
"Run the query associated with a Card, and return its results as CSV. Note that this expects the query as serialized JSON in the 'query' parameters."
[card-id query]
{query (s/maybe su/JSONString)}
(dataset-api/as-csv (run-query-for-card card-id (when query
(:parameters (json/parse-string query keyword))))))
(dataset-api/as-csv (run-query-for-card card-id (:parameters (json/parse-string query keyword)))))
(defendpoint GET "/:card-id/json"
"Fetch the results of a Card as JSON."
[card-id]
(let [{{:keys [columns rows]} :data} (run-query-for-card card-id nil)]
(for [row rows]
(zipmap columns row))))
(defendpoint POST "/:card-id/query/json"
"Run the query associated with a Card, and return its results as JSON. Note that this expects the query as serialized JSON in the 'query' parameters."
[card-id query]
{query (s/maybe su/JSONString)}
(dataset-api/as-json (run-query-for-card card-id (:parameters (json/parse-string query keyword)))))
(define-routes)
......@@ -65,6 +65,20 @@
{:status 500
:body (:error response)}))
(defn as-json
"Return a JSON response containing the RESULTS of a query."
{:arglists '([results])}
[{{:keys [columns rows]} :data, :keys [status], :as response}]
(if (= status :completed)
;; successful query, send CSV file
{:status 200
:body (for [row rows]
(zipmap columns row))
:headers {"Content-Disposition" (str "attachment; filename=\"query_result_" (u/date->iso-8601) ".json\"")}}
;; failed query, send error message
{:status 500
:body { :error (:error response) }}))
(defendpoint POST "/csv"
"Execute a query and download the result data as a CSV file."
[query]
......@@ -73,5 +87,13 @@
(read-check Database (:database query))
(as-csv (qp/dataset-query query {:executed-by *current-user-id*}))))
(defendpoint POST "/json"
"Execute a query and download the result data as a JSON file."
[query]
{query su/JSONString}
(let [query (json/parse-string query keyword)]
(read-check Database (:database query))
(as-json (qp/dataset-query query {:executed-by *current-user-id*}))))
(define-routes)
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