Skip to content
Snippets Groups Projects
Commit 500923d1 authored by Allen Gilliland's avatar Allen Gilliland
Browse files

very quick implementation of parameterized cards.

parent 1bdc50d4
No related branches found
No related tags found
No related merge requests found
......@@ -52,6 +52,7 @@ CardControllers.controller('CardDetail', [
isEditing = false,
isObjectDetail = false,
isShowingTutorial = false,
isShowingQueryBar = true,
isShowingNewbModal = false,
card = {
name: null,
......@@ -135,6 +136,7 @@ CardControllers.controller('CardDetail', [
onChangeLocation: function(url) {
$timeout(() => $location.url(url))
},
toggleQueryBarVisibility: toggleQueryBarVisibility,
toggleDataReferenceFn: toggleDataReference,
onBeginEditing: function() {
isEditing = true;
......@@ -152,7 +154,8 @@ CardControllers.controller('CardDetail', [
MetabaseAnalytics.trackEvent('QueryBuilder', 'Restore Original');
},
cardIsNewFn: cardIsNew,
cardIsDirtyFn: cardIsDirty
cardIsDirtyFn: cardIsDirty,
setQuery: onQueryChanged
};
var editorModel = {
......@@ -376,6 +379,7 @@ CardControllers.controller('CardDetail', [
// ensure rendering model is up to date
editorModel.isRunning = isRunning;
editorModel.isShowingDataReference = $scope.isShowingDataReference;
editorModel.isShowingQueryBar = isShowingQueryBar;
editorModel.isShowingTutorial = isShowingTutorial;
editorModel.databases = databases;
editorModel.tableMetadata = tableMetadata;
......@@ -477,6 +481,10 @@ CardControllers.controller('CardDetail', [
let startTime = new Date();
/// deal with adding parameters to the query if we have them
// dataset_query = angular.clone(dataset_query);
// dataset_query.parameters = [{name: "test", value: "Widget", field: ["field-id", 123]}];
// make our api call
Metabase.dataset({ timeout: cancelQueryDeferred.promise }, dataset_query, function (result) {
queryResult = result;
......@@ -780,6 +788,12 @@ CardControllers.controller('CardDetail', [
}
}
function toggleQueryBarVisibility() {
console.log("toggling");
isShowingQueryBar = !isShowingQueryBar;
renderAll();
}
function toggleDataReference() {
$scope.$apply(function() {
$scope.isShowingDataReference = !$scope.isShowingDataReference;
......
......@@ -7,6 +7,8 @@ import Expressions from "./expressions/Expressions.jsx";
import ExpressionWidget from './expressions/ExpressionWidget.jsx';
import LimitWidget from "./LimitWidget.jsx";
import SortWidget from "./SortWidget.jsx";
import Parameters from "./parameters/Parameters.jsx";
import ParameterWidget from "./parameters/ParameterWidget.jsx";
import Popover from "metabase/components/Popover.jsx";
import MetabaseAnalytics from "metabase/lib/analytics";
......@@ -20,7 +22,8 @@ export default class ExtendedOptions extends Component {
this.state = {
isOpen: false,
editExpression: null
editExpression: null,
editParameter: null
};
_.bindAll(
......@@ -37,7 +40,8 @@ export default class ExtendedOptions extends Component {
};
static defaultProps = {
expressions: {}
expressions: {},
parameters: []
};
......@@ -99,6 +103,41 @@ export default class ExtendedOptions extends Component {
MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove Expression');
}
setParameter(parameter, previousName) {
console.log("setting parameter", parameter, previousName);
let parameters = this.props.query.parameters || [];
if (!_.isEmpty(previousName)) {
// remove old expression using original name. this accounts for case where parameter is renamed.
parameters = _.reject(parameters, (p) => p.name === previousName);
}
// now add the new parameter
parameters = [...parameters, parameter];
this.props.query.parameters = parameters;
this.props.setQuery(this.props.query);
this.setState({editParameter: null});
console.log("parameters", this.props.query.parameters);
MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Parameter', !_.isEmpty(previousName));
}
removeParameter(parameter) {
console.log("remove parameter", parameter);
let parameters = this.props.query.parameters || [];
parameters = _.reject(parameters, (p) => p.name === parameter.name);
this.props.query.parameters = parameters;
this.props.setQuery(this.props.query);
this.setState({editParameter: null});
console.log("parameters", this.props.query.parameters);
MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove Parameter');
}
renderSort() {
if (!this.props.features.limit) {
return;
......@@ -171,6 +210,29 @@ export default class ExtendedOptions extends Component {
);
}
renderParametersWidget() {
// if we aren't editing any parameter then there is nothing to do
if (!this.state.editParameter || !this.props.tableMetadata) return null;
const parameters = this.props.query.parameters,
parameter = parameters && _.find(parameters, (p) => p.name === this.state.editParameter),
name = _.isString(this.state.editParameter) ? this.state.editParameter : "";
// TODO: at some point we need to prevent the add parameter button if there are none possible?
// TODO: pass in names that aren't allowed to be used (to prevent dupes)
return (
<Popover onClose={() => this.setState({editParameter: null})}>
<ParameterWidget
parameter={parameter}
tableMetadata={this.props.tableMetadata}
onSetParameter={(newParameter) => this.setParameter(newParameter, name)}
onRemoveParameter={(parameter) => this.removeParameter(parameter)}
onCancel={() => this.setState({editParameter: null})}
/>
</Popover>
);
}
renderPopover() {
if (!this.state.isOpen) return null;
......@@ -193,6 +255,16 @@ export default class ExtendedOptions extends Component {
/>
: null}
<Parameters
parameters={query.parameters}
tableMetadata={tableMetadata}
onAddParameter={() => this.setState({isOpen: false, editParameter: true})}
onEditParameter={(name) => {
this.setState({isOpen: false, editParameter: name});
MetabaseAnalytics.trackEvent("QueryBuilder", "Show Edit Parameter");
}}
/>
{ features.limit &&
<div>
<div className="mb1 h6 text-uppercase text-grey-3 text-bold">Row limit</div>
......@@ -215,6 +287,7 @@ export default class ExtendedOptions extends Component {
<span className={cx("EllipsisButton no-decoration text-grey-1 px1", {"cursor-pointer": onClick})} onClick={onClick}></span>
{this.renderPopover()}
{this.renderExpressionWidget()}
{this.renderParametersWidget()}
</div>
);
}
......
......@@ -7,6 +7,7 @@ import DataSelector from './DataSelector.jsx';
import ExtendedOptions from "./ExtendedOptions.jsx";
import FilterList from './filters/FilterList.jsx';
import FilterPopover from './filters/FilterPopover.jsx';
import ParameterPicker from "./parameters/ParameterPicker.jsx";
import Icon from "metabase/components/Icon.jsx";
import IconBorder from 'metabase/components/IconBorder.jsx';
import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx";
......@@ -109,6 +110,25 @@ export default class GuiQueryEditor extends Component {
MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove Filter');
}
setParameterValue(parameter, value) {
let parameters = this.props.query.parameters,
param = _.find(parameters, (p) => p.name === parameter.name);
// ick, mutability :(
if (value && !_.isEmpty(value)) {
param.value = value;
} else {
delete param.value;
}
this.setQuery(this.props.query);
console.log("set parameter value", parameter, value);
console.log("parameters", this.props.query.parameters);
MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Parameter Value'); // parameter type?
}
renderAdd(text, onClick, targetRefName) {
let className = "text-grey-2 text-bold flex align-center text-grey-4-hover cursor-pointer no-decoration transition-color";
if (onClick) {
......@@ -355,20 +375,36 @@ export default class GuiQueryEditor extends Component {
render() {
return (
<div className={cx("GuiBuilder rounded shadowed", { "GuiBuilder--expand": this.state.expanded })} ref="guiBuilder">
<div className="GuiBuilder-row flex">
{this.renderDataSection()}
{this.renderFilterSection()}
</div>
<div className="GuiBuilder-row flex flex-full">
{this.renderViewSection()}
<div className="flex-full"></div>
{this.props.children}
<ExtendedOptions
{...this.props}
setQuery={(query) => this.setQuery(query)}
/>
</div>
<div>
{this.props.isShowingQueryBar ?
<div className={cx("GuiBuilder rounded shadowed", { "GuiBuilder--expand": this.state.expanded })} ref="guiBuilder">
<div className="GuiBuilder-row flex">
{this.renderDataSection()}
{this.renderFilterSection()}
</div>
<div className="GuiBuilder-row flex flex-full">
{this.renderViewSection()}
<div className="flex-full"></div>
{this.props.children}
<ExtendedOptions
{...this.props}
setQuery={(query) => this.setQuery(query)}
/>
</div>
</div>
:
<span ref="guiBuilder" />
}
{this.props.query.parameters && this.props.query.parameters.length > 0 &&
<div className="flex flex-row" ref="parameters">
{this.props.query.parameters.map(parameter =>
<ParameterPicker
parameter={parameter}
onChange={(value) => this.setParameterValue(parameter, value)} />
)}
</div>
}
</div>
);
}
......
......@@ -19,6 +19,10 @@ import MetabaseAnalytics from "metabase/lib/analytics";
import Query from "metabase/lib/query";
import { cancelable } from "metabase/lib/promise";
import ParametersPopover from "./parameters/ParametersPopover.jsx";
import ParameterWidget from "./parameters/ParameterWidget.jsx";
import Popover from "metabase/components/Popover.jsx";
import cx from "classnames";
import _ from "underscore";
......@@ -29,12 +33,13 @@ export default class QueryHeader extends Component {
this.state = {
recentlySaved: null,
modal: null,
revisions: null
revisions: null,
isShowingQueryBar: true
};
_.bindAll(this, "resetStateOnTimeout",
"onCreate", "onSave", "onBeginEditing", "onCancel", "onDelete",
"onFollowBreadcrumb", "onToggleDataReference",
"onFollowBreadcrumb", "onToggleDataReference", "onToggleQueryBarVisibility",
"onFetchRevisions", "onRevertToRevision", "onRevertedRevision"
);
}
......@@ -146,6 +151,10 @@ export default class QueryHeader extends Component {
this.props.onRestoreOriginalQuery();
}
onToggleQueryBarVisibility() {
this.props.toggleQueryBarVisibility();
}
onToggleDataReference() {
this.props.toggleDataReferenceFn();
}
......@@ -168,6 +177,40 @@ export default class QueryHeader extends Component {
this.refs.cardHistory.toggle();
}
setParameter(parameter, previousName) {
console.log("setting parameter", parameter, previousName);
let parameters = this.props.card && this.props.card.dataset_query && this.props.card.dataset_query.parameters || [];
if (!_.isEmpty(previousName)) {
// remove old expression using original name. this accounts for case where parameter is renamed.
parameters = _.reject(parameters, (p) => p.name === previousName);
}
// now add the new parameter
parameters = [...parameters, parameter];
this.props.card.dataset_query.parameters = parameters;
this.props.setQuery(this.props.card.dataset_query);
console.log("parameters", this.props.card.dataset_query.parameters);
MetabaseAnalytics.trackEvent('QueryBuilder', 'Set Parameter', !_.isEmpty(previousName));
}
removeParameter(parameter) {
console.log("remove parameter", parameter);
let parameters = this.props.card && this.props.card.dataset_query && this.props.card.dataset_query.parameters || [];
parameters = _.reject(parameters, (p) => p.name === parameter.name);
this.props.card.dataset_query.parameters = parameters;
this.props.setQuery(this.props.card.dataset_query);
this.setState({editParameter: null});
console.log("parameters", this.props.card.dataset_query.parameters);
MetabaseAnalytics.trackEvent('QueryBuilder', 'Remove Parameter');
}
getHeaderButtons() {
var buttonSections = [];
......@@ -257,6 +300,30 @@ export default class QueryHeader extends Component {
}
}
// parameters
const paramsClasses = cx('mx1 transition-color', {
'text-grey-4': !this.props.isShowingDataReference,
'text-brand': this.props.isShowingDataReference,
'text-brand-hover': !this.state.isShowingDataReference});
buttonSections.push([
<span>
<a key="parameters" className={paramsClasses} title="Add quick parameters">
<Icon name='filter' width="16px" height="16px" onClick={() => this.setState({ modal: "parameters" })}></Icon>
</a>
{this.state.modal && this.state.modal === "parameters" &&
<Popover onClose={() => this.setState({modal: false})}>
<ParametersPopover
parameters={this.props.card.dataset_query.parameters}
tableMetadata={this.props.tableMetadata}
onSetParameter={(newParameter) => this.setParameter(newParameter, name)}
onRemoveParameter={(parameter) => this.removeParameter(parameter)}
onClose={() => this.setState({modal: false})}
/>
</Popover>
}
</span>
]);
// add to dashboard
if (!this.props.cardIsNewFn() && !this.props.isEditing) {
// simply adding an existing saved card to a dashboard, so show the modal to do so
......@@ -323,6 +390,18 @@ export default class QueryHeader extends Component {
/>
]);
// query bar toggle
var queryBarToggleClasses = cx('mx1 transition-color', {
'text-grey-4': !this.props.isShowingQueryBar,
'text-brand': this.props.isShowingQueryBar,
'text-brand-hover': !this.state.isShowingQueryBar
});
buttonSections.push([
<a key="queryBarToggle" className={queryBarToggleClasses} title="Toggle the full query bar">
<Icon name='chevrondown' width="16px" height="16px" onClick={this.onToggleQueryBarVisibility}></Icon>
</a>
]);
// data reference button
var dataReferenceButtonClasses = cx('mr1 transition-color', {
'text-brand': this.props.isShowingDataReference,
......
import React, { Component, PropTypes } from 'react';
import cx from "classnames";
import _ from 'underscore';
export default class ParameterPicker extends Component {
static propTypes = {
parameter: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired
};
render() {
const { parameter } = this.props;
return (
<div className="pt1">
<span className="mt3 h5 text-uppercase text-grey-3 text-bold">{parameter.name}:</span>
<input
className="m1 p1 input h4 text-dark"
type="text"
value={parameter.value}
placeholder=""
onChange={(event) => this.props.onChange(event.target.value)}
/>
</div>
);
}
}
import React, { Component, PropTypes } from 'react';
import cx from "classnames";
import _ from 'underscore';
import FieldList from "../FieldList.jsx";
import Query from "metabase/lib/query";
export default class ParameterWidget extends Component {
static propTypes = {
parameter: PropTypes.object,
tableMetadata: PropTypes.object.isRequired,
onSetParameter: PropTypes.func.isRequired,
onRemoveParameter: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired
};
static defaultProps = {
parameter: null,
}
componentWillMount() {
this.componentWillReceiveProps(this.props);
}
componentWillReceiveProps(newProps) {
this.setState({
parameter: newProps.parameter
});
}
isValid() {
const { parameter, error } = this.state;
return (parameter && !_.isEmpty(parameter.name) && !error) ; //&& isExpression(expression));
}
setField(field) {
console.log("setting field", field);
if (_.isNumber(field)) {
field = ["field-id", field];
}
let parameter = this.state.parameter;
this.setState({
parameter: {...parameter, field: field, value: "Widget"}
});
}
render() {
const { parameter } = this.state;
const { tableMetadata } = this.props;
console.log("param=", this.props.parameter);
return (
<div style={{maxWidth: "500px"}}>
<div className="p2">
<div className="h5 text-uppercase text-grey-3 text-bold">Pick the field</div>
<div>
<FieldList
className={"text-brand"}
tableMetadata={this.props.tableMetadata}
field={parameter && parameter.field}
fieldOptions={Query.getFieldOptions(tableMetadata.fields, true, tableMetadata.breakout_options.validFieldsFilter, {})}
onFieldChange={(f) => this.setField(f)}
enableTimeGrouping={false}
/>
<p className="h5 text-grey-2">
Pick a field that you often want to filter and we'll promote it.
</p>
</div>
<div className="mt3 h5 text-uppercase text-grey-3 text-bold">Give it a name</div>
<div>
<input
className="my1 p1 input block full h4 text-dark"
type="text"
value={parameter && parameter.name || ""}
placeholder="Something nice and descriptive"
onChange={(event) => this.setState({parameter: {...parameter, name: event.target.value}})}
/>
</div>
</div>
<div className="mt2 p2 border-top flex flex-row align-center justify-between">
<div>
<button
className={cx("Button", {"Button--primary": this.isValid()})}
onClick={() => this.props.onSetParameter(this.state.parameter)}
disabled={!this.isValid()}>{this.props.parameter ? "Update" : "Done"}</button>
<span className="pl1">or</span> <a className="link" onClick={() => this.props.onCancel()}>Cancel</a>
</div>
<div>
{this.props.parameter ?
<a className="pr2 text-warning link" onClick={() => this.props.onRemoveParameter(this.props.parameter)}>Remove</a>
: null }
</div>
</div>
</div>
);
}
}
import React, { Component, PropTypes } from "react";
import _ from "underscore";
import Icon from "metabase/components/Icon.jsx";
import IconBorder from "metabase/components/IconBorder.jsx";
import Tooltip from "metabase/components/Tooltip.jsx";
import { formatExpression } from "metabase/lib/expressions";
export default class Parameters extends Component {
static propTypes = {
parameters: PropTypes.array,
tableMetadata: PropTypes.object,
onAddParameter: PropTypes.func.isRequired,
onEditParameter: PropTypes.func.isRequired
};
static defaultProps = {
parameters: []
};
render() {
const { parameters, onAddParameter, onEditParameter } = this.props;
let sortedParameters = _.sortBy(parameters, "name");
return (
<div className="pb3">
<div className="pb1 h6 text-uppercase text-grey-3 text-bold">Parameters</div>
{ sortedParameters && sortedParameters.map(param =>
<div key={param.name} className="pb1 text-brand text-bold cursor-pointer flex flex-row align-center justify-between" onClick={() => onEditParameter(param.name)}>
<span>{param.name}</span>
</div>
)}
<a data-metabase-event={"QueryBuilder;Show Add Parameter"} className="text-grey-2 text-bold flex align-center text-grey-4-hover cursor-pointer no-decoration transition-color" onClick={() => onAddParameter()}>
<IconBorder borderRadius="3px">
<Icon name="add" width="14px" height="14px" />
</IconBorder>
<span className="ml1">Add a parameter</span>
</a>
</div>
);
}
}
import React, { Component, PropTypes } from "react";
import _ from "underscore";
import Icon from "metabase/components/Icon.jsx";
import IconBorder from "metabase/components/IconBorder.jsx";
import Tooltip from "metabase/components/Tooltip.jsx";
import ParameterWidget from "./ParameterWidget.jsx";
import { formatExpression } from "metabase/lib/expressions";
export default class ParametersPopover extends Component {
constructor(props, context) {
super(props, context);
this.state = {
editParameter: null
};
}
static propTypes = {
parameters: PropTypes.array,
tableMetadata: PropTypes.object,
onSetParameter: PropTypes.func.isRequired,
onRemoveParameter: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired
};
static defaultProps = {
parameters: []
};
renderParametersWidget() {
// if we aren't editing any parameter then there is nothing to do
if (!this.state.editParameter || !this.props.tableMetadata) return null;
const { parameters } = this.props,
parameter = parameters && _.find(parameters, (p) => p.name === this.state.editParameter),
name = _.isString(this.state.editParameter) ? this.state.editParameter : "";
// TODO: at some point we need to prevent the add parameter button if there are none possible?
// TODO: pass in names that aren't allowed to be used (to prevent dupes)
return (
<ParameterWidget
parameter={parameter}
tableMetadata={this.props.tableMetadata}
onSetParameter={(newParameter) => {
this.props.onSetParameter(newParameter, name);
this.props.onClose();
}}
onRemoveParameter={(parameter) => {
this.props.onRemoveParameter(parameter);
this.props.onClose();
}}
onCancel={() => this.setState({editParameter: null})}
/>
);
}
render() {
const { parameters } = this.props;
let sortedParameters = _.sortBy(parameters, "name");
return (
<div>
{!this.state.editParameter &&
<div style={{minWidth: "350px"}} className="p3">
<div className="pb1 h6 text-uppercase text-grey-3 text-bold">Parameters</div>
{ sortedParameters && sortedParameters.map(param =>
<div key={param.name} className="pb1 text-brand text-bold cursor-pointer flex flex-row align-center justify-between" onClick={() => this.setState({editParameter: param.name})}>
<span>{param.name}</span>
</div>
)}
<a data-metabase-event={"QueryBuilder;Show Add Parameter"} className="text-grey-2 text-bold flex align-center text-grey-4-hover cursor-pointer no-decoration transition-color" onClick={() => this.setState({editParameter: true})}>
<IconBorder borderRadius="3px">
<Icon name="add" width="14px" height="14px" />
</IconBorder>
<span className="ml1">Add a parameter</span>
</a>
</div>
}
{this.renderParametersWidget()}
</div>
);
}
}
......@@ -16,6 +16,7 @@
[expand :as expand]
[interface :refer :all]
[macros :as macros]
[parameters :as params]
[resolve :as resolve])
[metabase.util :as u])
(:import (schema.utils NamedError ValidationError)))
......@@ -140,6 +141,19 @@
(log/debug (u/format-color 'cyan "\n\nMACRO/SUBSTITUTED: 😻\n%s" (u/pprint-to-str <>))))))]
(qp query))))
(defn- pre-substitute-parameters
"Looks for macros in a structured (unexpanded) query and substitutes the macros for their contents."
[qp]
(fn [query]
;; if necessary, handle parameters substitution
(let [query (if-not (mbql-query? query)
query
;; for MBQL queries run our macro expansion
(u/prog1 (params/expand-parameters query)
(when (and (not *disable-qp-logging*)
(not= <> query))
(log/debug (u/format-color 'cyan "\n\nMACRO/SUBSTITUTED: 😻\n%s" (u/pprint-to-str <>))))))]
(qp query))))
(defn- pre-expand-resolve
"Transforms an MBQL into an expanded form with more information and structure. Also resolves references to fields, tables,
......@@ -500,6 +514,7 @@
((<<- wrap-catch-exceptions
pre-add-settings
pre-expand-macros
pre-substitute-parameters
pre-expand-resolve
driver-process-in-context
post-add-row-count-and-status
......
(ns metabase.query-processor.parameters
(:require [clojure.core.match :refer [match]]
[clojure.tools.logging :as log]))
(defn- merge-filter-clauses [base addtl]
(cond
(and (seq base)
(seq addtl)) ["AND" base addtl]
(seq base) base
(seq addtl) addtl
:else []))
(defn- expand-params [query-dict [{:keys [field value], :as param} & rest]]
(if param
;; NOTE: we always use a simple equals filter for parameters
(if (and param field value)
(let [filter-subclause ["=" field value]
_ (log/info "adding parameter clause: " filter-subclause)
query (assoc-in query-dict [:query :filter] (merge-filter-clauses (get-in query-dict [:query :filter]) filter-subclause))]
(expand-params query rest))
(expand-params query-dict rest))
query-dict))
(defn expand-parameters
"Expand any :parameters set on the QUERY-DICT."
[{:keys [parameters], :as query-dict}]
(let [query (dissoc query-dict :parameters)]
(if-not parameters
query
(expand-params query parameters))))
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