diff --git a/superset/assets/javascripts/components/ColumnOption.jsx b/superset/assets/javascripts/components/ColumnOption.jsx
new file mode 100644
index 0000000000000..c150937a0b2d1
--- /dev/null
+++ b/superset/assets/javascripts/components/ColumnOption.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
+
+const propTypes = {
+ column: PropTypes.object.isRequired,
+};
+
+export default function ColumnOption({ column }) {
+ return (
+
+
+ {column.verbose_name || column.column_name}
+
+ {column.description &&
+
+ }
+ {column.expression && column.expression !== column.column_name &&
+
+ }
+ );
+}
+ColumnOption.propTypes = propTypes;
diff --git a/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx b/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx
index d0763fd19c6aa..07b4db473e3a6 100644
--- a/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx
+++ b/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx
@@ -6,17 +6,23 @@ import { slugify } from '../modules/utils';
const propTypes = {
label: PropTypes.string.isRequired,
tooltip: PropTypes.string.isRequired,
+ icon: PropTypes.string,
+ className: PropTypes.string,
+};
+const defaultProps = {
+ icon: 'question-circle-o',
};
-export default function InfoTooltipWithTrigger({ label, tooltip }) {
+export default function InfoTooltipWithTrigger({ label, tooltip, icon, className }) {
return (
{tooltip}}
>
-
+
);
}
InfoTooltipWithTrigger.propTypes = propTypes;
+InfoTooltipWithTrigger.defaultProps = defaultProps;
diff --git a/superset/assets/javascripts/components/MetricOption.jsx b/superset/assets/javascripts/components/MetricOption.jsx
new file mode 100644
index 0000000000000..842761199c64a
--- /dev/null
+++ b/superset/assets/javascripts/components/MetricOption.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
+
+const propTypes = {
+ metric: PropTypes.object.isRequired,
+};
+
+export default function MetricOption({ metric }) {
+ return (
+
+
+ {metric.verbose_name || metric.metric_name}
+
+ {metric.description &&
+
+ }
+
+
);
+}
+MetricOption.propTypes = propTypes;
diff --git a/superset/assets/javascripts/explore/components/ChartContainer.jsx b/superset/assets/javascripts/explore/components/ChartContainer.jsx
index 9132683b03e13..2070b3db8b212 100644
--- a/superset/assets/javascripts/explore/components/ChartContainer.jsx
+++ b/superset/assets/javascripts/explore/components/ChartContainer.jsx
@@ -69,14 +69,15 @@ class ChartContainer extends React.PureComponent {
getMockedSliceObject() {
const props = this.props;
const getHeight = () => {
- const headerHeight = this.props.standalone ? 0 : 100;
+ const headerHeight = props.standalone ? 0 : 100;
return parseInt(props.height, 10) - headerHeight;
};
return {
- viewSqlQuery: this.props.queryResponse.query,
+ viewSqlQuery: props.queryResponse.query,
containerId: props.containerId,
+ datasource: props.datasource,
selector: this.state.selector,
- formData: this.props.formData,
+ formData: props.formData,
container: {
html: (data) => {
// this should be a callback to clear the contents of the slice container
@@ -128,10 +129,9 @@ class ChartContainer extends React.PureComponent {
},
data: {
- csv_endpoint: getExploreUrl(this.props.formData, 'csv'),
- json_endpoint: getExploreUrl(this.props.formData, 'json'),
- standalone_endpoint: getExploreUrl(
- this.props.formData, 'standalone'),
+ csv_endpoint: getExploreUrl(props.formData, 'csv'),
+ json_endpoint: getExploreUrl(props.formData, 'json'),
+ standalone_endpoint: getExploreUrl(props.formData, 'standalone'),
},
};
@@ -308,6 +308,7 @@ function mapStateToProps(state) {
chartStatus: state.chartStatus,
chartUpdateEndTime: state.chartUpdateEndTime,
chartUpdateStartTime: state.chartUpdateStartTime,
+ datasource: state.datasource,
column_formats: state.datasource ? state.datasource.column_formats : null,
containerId: state.slice ? `slice-container-${state.slice.slice_id}` : 'slice-container',
formData,
diff --git a/superset/assets/javascripts/explore/components/controls/SelectControl.jsx b/superset/assets/javascripts/explore/components/controls/SelectControl.jsx
index 2f8f678fa04c4..6998c071b0e92 100644
--- a/superset/assets/javascripts/explore/components/controls/SelectControl.jsx
+++ b/superset/assets/javascripts/explore/components/controls/SelectControl.jsx
@@ -15,6 +15,10 @@ const propTypes = {
onChange: PropTypes.func,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.array]),
showHeader: PropTypes.bool,
+ optionRenderer: PropTypes.func,
+ valueRenderer: PropTypes.func,
+ valueKey: PropTypes.string,
+ options: PropTypes.array,
};
const defaultProps = {
@@ -27,6 +31,9 @@ const defaultProps = {
multi: false,
onChange: () => {},
showHeader: true,
+ optionRenderer: opt => opt.label,
+ valueRenderer: opt => opt.label,
+ valueKey: 'value',
};
export default class SelectControl extends React.PureComponent {
@@ -42,14 +49,17 @@ export default class SelectControl extends React.PureComponent {
}
}
onChange(opt) {
- let optionValue = opt ? opt.value : null;
+ let optionValue = opt ? opt[this.props.valueKey] : null;
// if multi, return options values as an array
if (this.props.multi) {
- optionValue = opt ? opt.map(o => o.value) : null;
+ optionValue = opt ? opt.map(o => o[this.props.valueKey]) : null;
}
this.props.onChange(optionValue);
}
getOptions(props) {
+ if (props.options) {
+ return props.options;
+ }
// Accepts different formats of input
const options = props.choices.map((c) => {
let option;
@@ -94,11 +104,13 @@ export default class SelectControl extends React.PureComponent {
placeholder: `Select (${this.state.options.length})`,
options: this.state.options,
value: this.props.value,
+ valueKey: this.props.valueKey,
autosize: false,
clearable: this.props.clearable,
isLoading: this.props.isLoading,
onChange: this.onChange,
- optionRenderer: opt => opt.label,
+ optionRenderer: this.props.optionRenderer,
+ valueRenderer: this.props.valueRenderer,
};
// Tab, comma or Enter will trigger a new option created for FreeFormSelect
const selectWrap = this.props.freeForm ?
diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx
index 5ceb03a327489..a8f9bbadf710e 100644
--- a/superset/assets/javascripts/explore/stores/controls.jsx
+++ b/superset/assets/javascripts/explore/stores/controls.jsx
@@ -1,6 +1,8 @@
import React from 'react';
import { formatSelectOptionsForRange, formatSelectOptions } from '../../modules/utils';
import * as v from '../validators';
+import MetricOption from '../../components/MetricOption';
+import ColumnOption from '../../components/ColumnOption';
const D3_FORMAT_DOCS = 'D3 format syntax: https://github.com/d3/d3-format';
@@ -18,6 +20,7 @@ const ROW_LIMIT_OPTIONS = [10, 50, 100, 250, 500, 1000, 5000, 10000, 50000];
const SERIES_LIMITS = [0, 5, 10, 25, 50, 100, 500];
+
export const TIME_STAMP_OPTIONS = [
['smart_date', 'Adaptative formating'],
['%m/%d/%Y', '%m/%d/%Y | 01/14/2019'],
@@ -58,10 +61,13 @@ export const controls = {
multi: true,
label: 'Metrics',
validators: [v.nonEmpty],
+ valueKey: 'metric_name',
+ optionRenderer: m => ,
+ valueRenderer: m => ,
default: control =>
control.choices && control.choices.length > 0 ? [control.choices[0][0]] : null,
mapStateToProps: state => ({
- choices: (state.datasource) ? state.datasource.metrics_combo : [],
+ options: (state.datasource) ? state.datasource.metrics : [],
}),
description: 'One or many metrics to display',
},
@@ -92,21 +98,29 @@ export const controls = {
label: 'Metric',
clearable: false,
description: 'Choose the metric',
+ validators: [v.nonEmpty],
+ optionRenderer: m => ,
+ valueRenderer: m => ,
+ valueKey: 'metric_name',
default: control =>
control.choices && control.choices.length > 0 ? control.choices[0][0] : null,
mapStateToProps: state => ({
- choices: (state.datasource) ? state.datasource.metrics_combo : null,
+ options: (state.datasource) ? state.datasource.metrics : [],
}),
},
metric_2: {
type: 'SelectControl',
label: 'Right Axis Metric',
- choices: [],
- default: [],
+ default: null,
+ validators: [v.nonEmpty],
+ clearable: true,
description: 'Choose a metric for right axis',
+ valueKey: 'metric_name',
+ optionRenderer: m => ,
+ valueRenderer: m => ,
mapStateToProps: state => ({
- choices: (state.datasource) ? state.datasource.metrics_combo : [],
+ options: (state.datasource) ? state.datasource.metrics : [],
}),
},
@@ -311,8 +325,11 @@ export const controls = {
label: 'Group by',
default: [],
description: 'One or many controls to group by',
+ optionRenderer: c => ,
+ valueRenderer: c => ,
+ valueKey: 'column_name',
mapStateToProps: state => ({
- choices: (state.datasource) ? state.datasource.gb_cols : [],
+ options: (state.datasource) ? state.datasource.columns : [],
}),
},
@@ -650,10 +667,14 @@ export const controls = {
x: {
type: 'SelectControl',
label: 'X Axis',
- default: null,
description: 'Metric assigned to the [X] axis',
+ default: null,
+ validators: [v.nonEmpty],
+ optionRenderer: m => ,
+ valueRenderer: m => ,
+ valueKey: 'metric_name',
mapStateToProps: state => ({
- choices: (state.datasource) ? state.datasource.metrics_combo : [],
+ options: (state.datasource) ? state.datasource.metrics : [],
}),
},
@@ -662,8 +683,12 @@ export const controls = {
label: 'Y Axis',
default: null,
description: 'Metric assigned to the [Y] axis',
+ validators: [v.nonEmpty],
+ optionRenderer: m => ,
+ valueRenderer: m => ,
+ valueKey: 'metric_name',
mapStateToProps: state => ({
- choices: (state.datasource) ? state.datasource.metrics_combo : [],
+ options: (state.datasource) ? state.datasource.metrics : [],
}),
},
@@ -671,8 +696,12 @@ export const controls = {
type: 'SelectControl',
label: 'Bubble Size',
default: null,
+ validators: [v.nonEmpty],
+ optionRenderer: m => ,
+ valueRenderer: m => ,
+ valueKey: 'metric_name',
mapStateToProps: state => ({
- choices: (state.datasource) ? state.datasource.metrics_combo : [],
+ options: (state.datasource) ? state.datasource.metrics : [],
}),
},
diff --git a/superset/assets/spec/javascripts/components/ColumnOption_spec.jsx b/superset/assets/spec/javascripts/components/ColumnOption_spec.jsx
new file mode 100644
index 0000000000000..29b4399ffb16e
--- /dev/null
+++ b/superset/assets/spec/javascripts/components/ColumnOption_spec.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+
+import ColumnOption from '../../../javascripts/components/ColumnOption';
+import InfoTooltipWithTrigger from '../../../javascripts/components/InfoTooltipWithTrigger';
+
+describe('ColumnOption', () => {
+ const defaultProps = {
+ column: {
+ column_name: 'foo',
+ verbose_name: 'Foo',
+ expression: 'SUM(foo)',
+ description: 'Foo is the greatest column of all',
+ },
+ };
+
+ let wrapper;
+ let props;
+ const factory = o => ;
+ beforeEach(() => {
+ wrapper = shallow(factory(defaultProps));
+ props = Object.assign({}, defaultProps);
+ });
+ it('is a valid element', () => {
+ expect(React.isValidElement()).to.equal(true);
+ });
+ it('shows a label with verbose_name', () => {
+ const lbl = wrapper.find('.option-label');
+ expect(lbl).to.have.length(1);
+ expect(lbl.first().text()).to.equal('Foo');
+ });
+ it('shows 2 InfoTooltipWithTrigger', () => {
+ expect(wrapper.find(InfoTooltipWithTrigger)).to.have.length(2);
+ });
+ it('shows only 1 InfoTooltipWithTrigger when no descr', () => {
+ props.column.description = null;
+ wrapper = shallow(factory(props));
+ expect(wrapper.find(InfoTooltipWithTrigger)).to.have.length(1);
+ });
+ it('shows a label with column_name when no verbose_name', () => {
+ props.column.verbose_name = null;
+ wrapper = shallow(factory(props));
+ expect(wrapper.find('.option-label').first().text()).to.equal('foo');
+ });
+});
diff --git a/superset/assets/spec/javascripts/components/MetricOption_spec.jsx b/superset/assets/spec/javascripts/components/MetricOption_spec.jsx
new file mode 100644
index 0000000000000..f3fc26e243173
--- /dev/null
+++ b/superset/assets/spec/javascripts/components/MetricOption_spec.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+
+import MetricOption from '../../../javascripts/components/MetricOption';
+import InfoTooltipWithTrigger from '../../../javascripts/components/InfoTooltipWithTrigger';
+
+describe('MetricOption', () => {
+ const defaultProps = {
+ metric: {
+ metric_name: 'foo',
+ verbose_name: 'Foo',
+ expression: 'SUM(foo)',
+ description: 'Foo is the greatest metric of all',
+ },
+ };
+
+ let wrapper;
+ let props;
+ const factory = o => ;
+ beforeEach(() => {
+ wrapper = shallow(factory(defaultProps));
+ props = Object.assign({}, defaultProps);
+ });
+ it('is a valid element', () => {
+ expect(React.isValidElement()).to.equal(true);
+ });
+ it('shows a label with verbose_name', () => {
+ const lbl = wrapper.find('.option-label');
+ expect(lbl).to.have.length(1);
+ expect(lbl.first().text()).to.equal('Foo');
+ });
+ it('shows 2 InfoTooltipWithTrigger', () => {
+ expect(wrapper.find(InfoTooltipWithTrigger)).to.have.length(2);
+ });
+ it('shows only 1 InfoTooltipWithTrigger when no descr', () => {
+ props.metric.description = null;
+ wrapper = shallow(factory(props));
+ expect(wrapper.find(InfoTooltipWithTrigger)).to.have.length(1);
+ });
+ it('shows a label with metric_name when no verbose_name', () => {
+ props.metric.verbose_name = null;
+ wrapper = shallow(factory(props));
+ expect(wrapper.find('.option-label').first().text()).to.equal('foo');
+ });
+});
diff --git a/superset/assets/stylesheets/superset.css b/superset/assets/stylesheets/superset.css
index 4dafda4221d6b..9db4ffde6a9e0 100644
--- a/superset/assets/stylesheets/superset.css
+++ b/superset/assets/stylesheets/superset.css
@@ -208,4 +208,7 @@ div.widget .slice_container {
.table-condensed input[type="checkbox"] {
float: left;
-}
\ No newline at end of file
+}
+.m-r-5 {
+ margin-right: 5px;
+}
diff --git a/superset/assets/visualizations/table.js b/superset/assets/visualizations/table.js
index 4a2709418ef8b..dcbcd9ac740f0 100644
--- a/superset/assets/visualizations/table.js
+++ b/superset/assets/visualizations/table.js
@@ -49,9 +49,11 @@ function tableVis(slice, payload) {
'table-condensed table-hover dataTable no-footer', true)
.attr('width', '100%');
+ const cols = data.columns.map(c => slice.datasource.verbose_map[c] || c);
+
table.append('thead').append('tr')
.selectAll('th')
- .data(data.columns)
+ .data(cols)
.enter()
.append('th')
.text(function (d) {
diff --git a/superset/connectors/base.py b/superset/connectors/base.py
index 982b3bd75898a..9694b06ac1696 100644
--- a/superset/connectors/base.py
+++ b/superset/connectors/base.py
@@ -104,7 +104,15 @@ def data(self):
order_by_choices.append((json.dumps([s, True]), s + ' [asc]'))
order_by_choices.append((json.dumps([s, False]), s + ' [desc]'))
- d = {
+ verbose_map = {
+ o.metric_name: o.verbose_name or o.metric_name
+ for o in self.metrics
+ }
+ verbose_map.update({
+ o.column_name: o.verbose_name or o.column_name
+ for o in self.columns
+ })
+ return {
'all_cols': utils.choicify(self.column_names),
'column_formats': self.column_formats,
'edit_url': self.url,
@@ -116,10 +124,11 @@ def data(self):
'name': self.name,
'order_by_choices': order_by_choices,
'type': self.type,
+ 'metrics': [o.data for o in self.metrics],
+ 'columns': [o.data for o in self.columns],
+ 'verbose_map': verbose_map,
}
- return d
-
def get_query_str(self, query_obj):
"""Returns a query as a string
@@ -196,6 +205,15 @@ def is_string(self):
any([t in self.type.upper() for t in self.str_types])
)
+ @property
+ def expression(self):
+ raise NotImplementedError()
+
+ @property
+ def data(self):
+ attrs = ('column_name', 'verbose_name', 'description', 'expression')
+ return {s: getattr(self, s) for s in attrs}
+
class BaseMetric(AuditMixinNullable, ImportMixin):
@@ -227,3 +245,12 @@ class BaseMetric(AuditMixinNullable, ImportMixin):
@property
def perm(self):
raise NotImplementedError()
+
+ @property
+ def expression(self):
+ raise NotImplementedError()
+
+ @property
+ def data(self):
+ attrs = ('metric_name', 'verbose_name', 'description', 'expression')
+ return {s: getattr(self, s) for s in attrs}
diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py
index 9add7b13d459a..e0a426d4fe5f3 100644
--- a/superset/connectors/druid/models.py
+++ b/superset/connectors/druid/models.py
@@ -144,6 +144,10 @@ class DruidColumn(Model, BaseColumn):
def __repr__(self):
return self.column_name
+ @property
+ def expression(self):
+ return self.dimension_spec_json
+
@property
def dimension_spec(self):
if self.dimension_spec_json:
@@ -277,6 +281,10 @@ class DruidMetric(Model, BaseMetric):
'json', 'description', 'is_restricted', 'd3format'
)
+ @property
+ def expression(self):
+ return self.json
+
@property
def json_obj(self):
try:
diff --git a/superset/views/base.py b/superset/views/base.py
index 893dddaf0c31c..f2cbd9bd0ec18 100644
--- a/superset/views/base.py
+++ b/superset/views/base.py
@@ -11,7 +11,6 @@
from flask_appbuilder.widgets import ListWidget
from flask_appbuilder.actions import action
from flask_appbuilder.models.sqla.filters import BaseFilter
-from flask_appbuilder.security.sqla import models as ab_models
from superset import appbuilder, conf, db, utils, sm, sql_parse
from superset.connectors.connector_registry import ConnectorRegistry
diff --git a/superset/views/core.py b/superset/views/core.py
index e10632179db66..b85f01306c833 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -1063,7 +1063,6 @@ def explore(self, datasource_type, datasource_id):
"can_download": slice_download_perm,
"can_overwrite": slice_overwrite_perm,
"datasource": datasource.data,
- # TODO: separate endpoint for fetching datasources
"form_data": form_data,
"datasource_id": datasource_id,
"datasource_type": datasource_type,