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,