diff --git a/OSX/Metabase/UI/MainViewController.m b/OSX/Metabase/UI/MainViewController.m index 91c7aeb5dff4d1111b82e892d268d32d0b9bb2c7..14263b876daff3ee92ebcaa5cfbfd273204f8889 100644 --- a/OSX/Metabase/UI/MainViewController.m +++ b/OSX/Metabase/UI/MainViewController.m @@ -115,33 +115,54 @@ NSString *BaseURL() { [self.webView.mainFrame loadRequest:request]; } -- (void)saveCSV:(NSString *)datasetQuery { +- (void)downloadWithMethod:(NSString *)methodString url:(NSString *)urlString params:(NSDictionary *)paramsDict extensions:(NSArray *)extensions { NSSavePanel *savePanel = [NSSavePanel savePanel]; - savePanel.allowedFileTypes = @[@"csv"]; - savePanel.allowsOtherFileTypes = NO; savePanel.extensionHidden = NO; savePanel.showsTagField = NO; + if ([extensions count] > 0) { + savePanel.allowedFileTypes = extensions; + savePanel.allowsOtherFileTypes = NO; + } NSString *downloadsDirectory = NSSearchPathForDirectoriesInDomains(NSDownloadsDirectory, NSUserDomainMask, YES)[0]; savePanel.directoryURL = [NSURL URLWithString:downloadsDirectory]; - + + // TODO: either figure out how to pull default filename from the Content-Disposition header or pass it in from JS land. NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; dateFormatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; dateFormatter.dateFormat = @"yyyy-MM-dd'T'HH_mm_ss"; savePanel.nameFieldStringValue = [NSString stringWithFormat:@"query_result_%@", [dateFormatter stringFromDate:[NSDate date]]]; if ([savePanel runModal] == NSFileHandlingPanelOKButton) { - NSLog(@"Will save CSV at: %@", savePanel.URL); + NSLog(@"Will save file at: %@", savePanel.URL); - NSURL *url = [NSURL URLWithString:@"/api/dataset/csv" relativeToURL:[NSURL URLWithString:BaseURL()]]; - NSString *postBody = [@"query=" stringByAppendingString:datasetQuery]; - NSData *data = [postBody dataUsingEncoding:NSUTF8StringEncoding]; - NSMutableURLRequest *csvRequest = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:10.0f]; - csvRequest.HTTPMethod = @"POST"; - [csvRequest setValue:@"application/x-www-form-urlencoded" forHTTPHeaderField:@"Content-Type"]; - [csvRequest setValue:[NSString stringWithFormat:@"%lu", (NSUInteger)data.length] forHTTPHeaderField:@"Content-Length"]; - csvRequest.HTTPBody = data; - [NSURLConnection sendAsynchronousRequest:csvRequest queue:[[NSOperationQueue alloc] init] completionHandler:^(NSURLResponse *response, NSData *data, NSError *connectionError) { + NSString *method = [methodString uppercaseString]; + + NSURL *url = [NSURL URLWithString:urlString relativeToURL:[NSURL URLWithString:BaseURL()]]; + + NSMutableString *query = [NSMutableString string]; + for (NSString* key in paramsDict) { + NSString* value = [paramsDict objectForKey:key]; + [query appendFormat:@"%@=%@", + [key stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]], + [value stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]]; + } + + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:10.0f]; + request.HTTPMethod = [method uppercaseString]; + + if ([query length] > 0) { + if ([request.HTTPMethod isEqualToString:@"POST"] || [request.HTTPMethod isEqualToString:@"PUT"]) { + NSData *data = [query dataUsingEncoding:NSUTF8StringEncoding]; + [request setValue:@"application/x-www-form-urlencoded" forHTTPHeaderField:@"Content-Type"]; + [request setValue:[NSString stringWithFormat:@"%lu", (NSUInteger)data.length] forHTTPHeaderField:@"Content-Length"]; + request.HTTPBody = data; + } else { + [request setURL:[NSURL URLWithString:[NSString stringWithFormat:@"%@?%@", urlString, query] relativeToURL:[NSURL URLWithString:BaseURL()]]]; + } + } + + [NSURLConnection sendAsynchronousRequest:request queue:[[NSOperationQueue alloc] init] completionHandler:^(NSURLResponse *response, NSData *data, NSError *connectionError) { NSError *writeError = nil; [data writeToURL:savePanel.URL options:NSDataWritingAtomic error:&writeError]; @@ -165,11 +186,11 @@ NSString *BaseURL() { }; // custom functions for OS X integration are available to the frontend as properties of window.OSX - context[@"OSX"] = @{@"saveCSV": ^(JSValue *datasetQuery){ - [self saveCSV:datasetQuery.description]; - }, @"resetPassword": ^(){ + context[@"OSX"] = @{@"download": ^(JSValue *method, JSValue *url, JSValue *params, JSValue *extensions) { + [self downloadWithMethod:[method toString] url:[url toString] params:[params toDictionary] extensions:[extensions toArray]]; + }, @"resetPassword": ^(){ [self resetPassword:nil]; - }}; + }}; } diff --git a/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx b/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx index 2913ed122cd7f4507ad3266258be4a5999b01b04..daa7e5f96e711a12641f1404f98c2c8a52408b9f 100644 --- a/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx +++ b/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx @@ -4,7 +4,7 @@ import { Link } from "react-router"; import FormLabel from "../components/FormLabel.jsx"; import FormInput from "../components/FormInput.jsx"; import FormTextArea from "../components/FormTextArea.jsx"; -import FieldSet from "../components/FieldSet.jsx"; +import FieldSet from "metabase/components/FieldSet.jsx"; import PartialQueryBuilder from "../components/PartialQueryBuilder.jsx"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx"; diff --git a/frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx b/frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx index 09f324407fa8632c99e1fe551243877327958827..f6b10dc6fdd91a127eb314e220f77c417750ab3c 100644 --- a/frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx +++ b/frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx @@ -4,7 +4,7 @@ import { Link } from "react-router"; import FormLabel from "../components/FormLabel.jsx"; import FormInput from "../components/FormInput.jsx"; import FormTextArea from "../components/FormTextArea.jsx"; -import FieldSet from "../components/FieldSet.jsx"; +import FieldSet from "metabase/components/FieldSet.jsx"; import PartialQueryBuilder from "../components/PartialQueryBuilder.jsx"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx"; diff --git a/frontend/src/metabase/components/Button.jsx b/frontend/src/metabase/components/Button.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8ce1b897e0fd8ceb120c0a765eb6d1a7e7d11980 --- /dev/null +++ b/frontend/src/metabase/components/Button.jsx @@ -0,0 +1,52 @@ +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> + ); +} + +Button.propTypes = { + className: PropTypes.string, + icon: PropTypes.string, + children: PropTypes.any, + + small: PropTypes.bool, + medium: PropTypes.bool, + large: PropTypes.bool, + + primary: PropTypes.bool, + warning: PropTypes.bool, + cancel: PropTypes.bool, + purple: PropTypes.bool, + + borderless: PropTypes.bool +}; + +export default Button; diff --git a/frontend/src/metabase/components/DownloadButton.jsx b/frontend/src/metabase/components/DownloadButton.jsx new file mode 100644 index 0000000000000000000000000000000000000000..003f86365b2dfb90d8328cd7ea40078ee118f94a --- /dev/null +++ b/frontend/src/metabase/components/DownloadButton.jsx @@ -0,0 +1,43 @@ +import React, { Component, PropTypes } from "react"; +import ReactDOM from "react-dom"; + +import Button from "metabase/components/Button.jsx"; + +const DownloadButton = ({ className, style, children, method, url, params, extensions, ...props }) => + <form className={className} style={style} method={method} action={url}> + { Object.entries(params).map(([name, value]) => + <input key={name} type="hidden" name={name} value={value} /> + )} + <Button + onClick={(e) => { + if (window.OSX) { + // prevent form from being submitted normally + e.preventDefault(); + // download using the API provided by the OS X app + window.OSX.download(method, url, params, extensions); + } + }} + {...props} + > + {children} + </Button> + </form> + +DownloadButton.propTypes = { + className: PropTypes.string, + style: PropTypes.object, + url: PropTypes.string.isRequired, + method: PropTypes.string, + params: PropTypes.object, + icon: PropTypes.string, + extensions: PropTypes.array, +}; + +DownloadButton.defaultProps = { + icon: "downarrow", + method: "POST", + params: {}, + extensions: [] +}; + +export default DownloadButton; diff --git a/frontend/src/metabase/admin/datamodel/components/FieldSet.jsx b/frontend/src/metabase/components/FieldSet.jsx similarity index 66% rename from frontend/src/metabase/admin/datamodel/components/FieldSet.jsx rename to frontend/src/metabase/components/FieldSet.jsx index 916baab703e79e38dbe9ca374820f776b281bd95..d303b0ece1ab0b7d9423d6d10df9707071a4492f 100644 --- a/frontend/src/metabase/admin/datamodel/components/FieldSet.jsx +++ b/frontend/src/metabase/components/FieldSet.jsx @@ -1,15 +1,17 @@ import React, { Component, PropTypes } from "react"; +import cx from "classnames"; + export default class FieldSet extends Component { static propTypes = {}; static defaultProps = { - border: "border-brand" + className: "border-brand" }; render() { - const { children, legend, border } = this.props; + const { className, children, legend } = this.props; return ( - <fieldset className={"px2 pb2 bordered rounded " + border}> + <fieldset className={cx(className, "px2 pb2 bordered rounded")}> {legend && <legend className="h5 text-bold text-uppercase px1" style={{ marginLeft: "-0.5rem" }}>{legend}</legend>} {children} </fieldset> diff --git a/frontend/src/metabase/icon_paths.js b/frontend/src/metabase/icon_paths.js index 8cdc18d482994f1abf53f5d9077450b17e1d5613..609209a7b821411e7a40815677aecd5fec59e3cc 100644 --- a/frontend/src/metabase/icon_paths.js +++ b/frontend/src/metabase/icon_paths.js @@ -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' } diff --git a/frontend/src/metabase/query_builder/components/DownloadWidget.jsx b/frontend/src/metabase/query_builder/components/DownloadWidget.jsx new file mode 100644 index 0000000000000000000000000000000000000000..bf0fb87cb2a7c908bceb13284d691251c69c6039 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/DownloadWidget.jsx @@ -0,0 +1,50 @@ +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"; + +const DownloadWidget = ({ className, card, datasetQuery, isLarge }) => + <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={card.id != null ? + { parameters: JSON.stringify(datasetQuery.parameters) } : + { query: JSON.stringify(datasetQuery) } + } + extensions={[type]} + > + {type} + </DownloadButton> + )} + </div> + </div> + </PopoverWithTrigger> + +DownloadWidget.propTypes = { + className: PropTypes.string, + card: PropTypes.object.isRequired, + datasetQuery: PropTypes.object.isRequired, + isLarge: PropTypes.bool +}; + +export default DownloadWidget; diff --git a/frontend/src/metabase/query_builder/components/QueryDefinitionTooltip.jsx b/frontend/src/metabase/query_builder/components/QueryDefinitionTooltip.jsx index 34a66a47c2e7b6b5a14c4a19ce02a09171103fdc..c34fbc9fcc6828d53b580e3d02c7aa8a5152b052 100644 --- a/frontend/src/metabase/query_builder/components/QueryDefinitionTooltip.jsx +++ b/frontend/src/metabase/query_builder/components/QueryDefinitionTooltip.jsx @@ -2,7 +2,7 @@ import React, { Component, PropTypes } from "react"; import FilterList from "./filters/FilterList.jsx"; import AggregationWidget from "./AggregationWidget.jsx"; -import FieldSet from "metabase/admin/datamodel/components/FieldSet.jsx"; +import FieldSet from "metabase/components/FieldSet.jsx"; import Query from "metabase/lib/query"; @@ -29,7 +29,7 @@ export default class QueryDefinitionTooltip extends Component { </div> { object.definition && <div className="mt2"> - <FieldSet legend="Definition" border="border-light"> + <FieldSet legend="Definition" className="border-light"> <div className="TooltipFilterList"> { object.definition.aggregation && <AggregationWidget diff --git a/frontend/src/metabase/query_builder/components/QueryVisualization.jsx b/frontend/src/metabase/query_builder/components/QueryVisualization.jsx index 4defd873bed8e38b216acb41f04e0d083b8a40cd..adfac0aad64e20422a2e238ec4767f43ad47ec60 100644 --- a/frontend/src/metabase/query_builder/components/QueryVisualization.jsx +++ b/frontend/src/metabase/query_builder/components/QueryVisualization.jsx @@ -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; diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx index 63da9c30d750e81cb397fe53d0b09c3c6282bf1b..7b6dddf3135ed10f2aa0a6bd7e6e26d7a55835a7 100644 --- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx +++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx @@ -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, diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js index 6910117602348c3141eeff167c3c9a003d2a98b9..8fa4c20c93308ac1be4e93d8b103ab02c08c8582 100644 --- a/frontend/src/metabase/query_builder/selectors.js +++ b/frontend/src/metabase/query_builder/selectors.js @@ -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) diff --git a/src/metabase/api/card.clj b/src/metabase/api/card.clj index d98c9c63e50e11b5377420491616f05df4313b22..709595bad6698693c98370d79730122571475607 100644 --- a/src/metabase/api/card.clj +++ b/src/metabase/api/card.clj @@ -1,5 +1,6 @@ (ns metabase.api.card (:require [clojure.data :as data] + [cheshire.core :as json] [compojure.core :refer [GET POST DELETE PUT]] [schema.core :as s] (metabase.api [common :refer :all] @@ -273,16 +274,16 @@ (run-query-for-card card-id parameters)) (defendpoint POST "/:card-id/query/csv" - "Run the query associated with a Card, and return its results as CSV." - [card-id :as {{:keys [parameters]} :body}] - (dataset-api/as-csv (run-query-for-card card-id parameters))) - -(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)))) + "Run the query associated with a Card, and return its results as CSV. Note that this expects the parameters as serialized JSON in the 'parameters' parameter" + [card-id parameters] + {parameters (s/maybe su/JSONString)} + (dataset-api/as-csv (run-query-for-card card-id (json/parse-string parameters keyword)))) + +(defendpoint POST "/:card-id/query/json" + "Run the query associated with a Card, and return its results as JSON. Note that this expects the parameters as serialized JSON in the 'parameters' parameter" + [card-id parameters] + {parameters (s/maybe su/JSONString)} + (dataset-api/as-json (run-query-for-card card-id (json/parse-string parameters keyword)))) (define-routes) diff --git a/src/metabase/api/dataset.clj b/src/metabase/api/dataset.clj index b9566fc7863d78fe1766f99e3a2c2a43f372b8ca..42c7789c124eb657047527b80fcc82ce8c9c7815 100644 --- a/src/metabase/api/dataset.clj +++ b/src/metabase/api/dataset.clj @@ -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) diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index 596e5b1729326d1d455f065758fc84ae31add59d..9b4dfb570eb70d193d06fe8e33323601c7a60fb6 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -1,6 +1,7 @@ (ns metabase.api.card-test "Tests for /api/card endpoints." - (:require [expectations :refer :all] + (:require [cheshire.core :as json] + [expectations :refer :all] [metabase.db :as db] [metabase.http-client :refer :all, :as http] [metabase.middleware :as middleware] @@ -317,8 +318,7 @@ Table [{table-id :id} {:db_id database-id, :name "CATEGORIES"}] Card [card {:dataset_query {:database database-id :type :native - :native {:query "SELECT COUNT(*) FROM CATEGORIES;"} - :query {:source-table table-id, :aggregation {:aggregation-type :count}}}}]] + :native {:query "SELECT COUNT(*) FROM CATEGORIES;"}}}]] ;; delete all permissions for this DB (perms/delete-related-permissions! (perms-group/all-users) (perms/object-path database-id)) (f database-id card))) @@ -349,4 +349,40 @@ (do-with-temp-native-card (fn [database-id card] (perms/grant-native-read-permissions! (perms-group/all-users) database-id) - ((user->client :rasta) :get 200 (format "card/%d/json" (u/get-id card)))))) + ((user->client :rasta) :post 200 (format "card/%d/query/json" (u/get-id card)))))) + + +;;; Test GET /api/card/:id/query/csv & GET /api/card/:id/json **WITH PARAMETERS** + +(defn- do-with-temp-native-card-with-params {:style/indent 0} [f] + (with-temp* [Database [{database-id :id} {:details (:details (Database (id))), :engine :h2}] + Table [{table-id :id} {:db_id database-id, :name "VENUES"}] + Card [card {:dataset_query {:database database-id + :type :native + :native {:query "SELECT COUNT(*) FROM VENUES WHERE CATEGORY_ID = {{category}};" + :template_tags {:category {:id "a9001580-3bcc-b827-ce26-1dbc82429163" + :name "category" + :display_name "Category" + :type "number" + :required true}}}}}]] + (f database-id card))) + +(def ^:private ^:const ^String encoded-params + (json/generate-string [{:type :category + :target [:variable [:template-tag :category]] + :value 2}])) + +;; CSV +(expect + (str "COUNT(*)\n" + "8\n") + (do-with-temp-native-card-with-params + (fn [database-id card] + ((user->client :rasta) :post 200 (format "card/%d/query/csv?parameters=%s" (u/get-id card) encoded-params))))) + +;; JSON +(expect + [{(keyword "COUNT(*)") 8}] + (do-with-temp-native-card-with-params + (fn [database-id card] + ((user->client :rasta) :post 200 (format "card/%d/query/json?parameters=%s" (u/get-id card) encoded-params)))))