[Feature/Bugfix] Datepicker and time granularity options to dashboard filters (#3508)

* Feature: added datepicker and time granularity options to dashboard filter

* Added option for Druid datasource time filters

* added more checkbox control over dashboard time filters
This commit is contained in:
Jeff Niu
2017-10-04 12:43:29 -07:00
committed by Maxime Beauchemin
parent ff268a7526
commit 7c936e7f60
10 changed files with 260 additions and 79 deletions

View File

@@ -175,7 +175,7 @@ export function dashboardContainer(dashboard, datasources, userid) {
const f = [];
const immuneSlices = this.metadata.filter_immune_slices || [];
if (sliceId && immuneSlices.includes(sliceId)) {
// The slice is immune to dashboard fiterls
// The slice is immune to dashboard filters
return f;
}
@@ -205,8 +205,13 @@ export function dashboardContainer(dashboard, datasources, userid) {
return f;
},
addFilter(sliceId, col, vals, merge = true, refresh = true) {
if (this.getSlice(sliceId) && (col === '__from' || col === '__to' ||
this.getSlice(sliceId).formData.groupby.indexOf(col) !== -1)) {
if (
this.getSlice(sliceId) && (
['__from', '__to', '__time_col', '__time_grain', '__time_origin', '__granularity']
.indexOf(col) >= 0 ||
this.getSlice(sliceId).formData.groupby.indexOf(col) !== -1
)
) {
if (!(sliceId in this.filters)) {
this.filters[sliceId] = {};
}

View File

@@ -562,6 +562,7 @@ export const controls = {
mapStateToProps: state => ({
choices: (state.datasource) ? state.datasource.granularity_sqla : [],
}),
freeForm: true,
},
time_grain_sqla: {
@@ -1020,6 +1021,34 @@ export const controls = {
description: t('Whether to include a time filter'),
},
show_sqla_time_granularity: {
type: 'CheckboxControl',
label: 'Show SQL Granularity Dropdown',
default: false,
description: 'Check to include SQL Granularity dropdown',
},
show_sqla_time_column: {
type: 'CheckboxControl',
label: 'Show SQL Time Column',
default: false,
description: 'Check to include Time Column dropdown',
},
show_druid_time_granularity: {
type: 'CheckboxControl',
label: 'Show Druid Granularity Dropdown',
default: false,
description: 'Check to include Druid Granularity dropdown',
},
show_druid_time_origin: {
type: 'CheckboxControl',
label: 'Show Druid Time Origin',
default: false,
description: 'Check to include Time Origin dropdown',
},
show_datatable: {
type: 'CheckboxControl',
label: t('Data Table'),

View File

@@ -902,12 +902,9 @@ export const visTypes = {
controlSetRows: [
['groupby'],
['metric'],
],
},
{
label: 'Options',
controlSetRows: [
['date_filter', 'instant_filtering'],
['show_sqla_time_granularity', 'show_sqla_time_column'],
['show_druid_time_granularity', 'show_druid_time_origin'],
],
},
],

View File

@@ -208,8 +208,7 @@ const px = function (state) {
this.force = force;
}
const formDataExtra = Object.assign({}, formData);
const extraFilters = controller.effectiveExtraFilters(sliceId);
formDataExtra.filters = formDataExtra.filters.concat(extraFilters);
formDataExtra.extra_filters = controller.effectiveExtraFilters(sliceId);
controls.find('a.exploreChart').attr('href', getExploreUrl(formDataExtra));
controls.find('a.exportCSV').attr('href', getExploreUrl(formDataExtra, 'csv'));
token.find('img.loading').show();

View File

@@ -7,6 +7,12 @@
padding-top: 0;
}
.input-inline {
float: left;
display: inline-block;
padding-right: 3px;
}
ul.select2-results li.select2-highlighted div.filter_box{
color: black;
border-width: 1px;

View File

@@ -6,23 +6,42 @@ import ReactDOM from 'react-dom';
import Select from 'react-select';
import { Button } from 'react-bootstrap';
import { TIME_CHOICES } from './constants';
import DateFilterControl from '../javascripts/explore/components/controls/DateFilterControl';
import ControlRow from '../javascripts/explore/components/ControlRow';
import Control from '../javascripts/explore/components/Control';
import controls from '../javascripts/explore/stores/controls';
import './filter_box.css';
import { t } from '../javascripts/locales';
// maps control names to their key in extra_filters
const timeFilterMap = {
since: '__from',
until: '__to',
granularity_sqla: '__time_col',
time_grain_sqla: '__time_grain',
druid_time_origin: '__time_origin',
granularity: '__granularity',
};
const propTypes = {
origSelectedValues: PropTypes.object,
instantFiltering: PropTypes.bool,
filtersChoices: PropTypes.object,
onChange: PropTypes.func,
showDateFilter: PropTypes.bool,
showSqlaTimeGrain: PropTypes.bool,
showSqlaTimeColumn: PropTypes.bool,
showDruidTimeGrain: PropTypes.bool,
showDruidTimeOrigin: PropTypes.bool,
datasource: PropTypes.object.isRequired,
};
const defaultProps = {
origSelectedValues: {},
onChange: () => {},
showDateFilter: false,
showSqlaTimeGrain: false,
showSqlaTimeColumn: false,
showDruidTimeGrain: false,
showDruidTimeOrigin: false,
instantFiltering: true,
};
@@ -34,46 +53,98 @@ class FilterBox extends React.Component {
hasChanged: false,
};
}
getControlData(controlName) {
const control = Object.assign({}, controls[controlName]);
const controlData = {
name: controlName,
key: `control-${controlName}`,
value: this.state.selectedValues[timeFilterMap[controlName]],
actions: { setControlValue: this.changeFilter.bind(this) },
};
Object.assign(control, controlData);
const mapFunc = control.mapStateToProps;
if (mapFunc) {
return Object.assign({}, control, mapFunc(this.props));
}
return control;
}
clickApply() {
this.props.onChange(Object.keys(this.state.selectedValues)[0], [], true, true);
this.setState({ hasChanged: false });
}
changeFilter(filter, options) {
const fltr = timeFilterMap[filter] || filter;
let vals = null;
if (options) {
if (options !== null) {
if (Array.isArray(options)) {
vals = options.map(opt => opt.value);
} else {
} else if (options.value) {
vals = options.value;
} else {
vals = options;
}
}
const selectedValues = Object.assign({}, this.state.selectedValues);
selectedValues[filter] = vals;
selectedValues[fltr] = vals;
this.setState({ selectedValues, hasChanged: true });
this.props.onChange(filter, vals, false, this.props.instantFiltering);
this.props.onChange(fltr, vals, false, this.props.instantFiltering);
}
render() {
let dateFilter;
const since = '__from';
const until = '__to';
if (this.props.showDateFilter) {
dateFilter = ['__from', '__to'].map((field) => {
const val = this.state.selectedValues[field];
const choices = TIME_CHOICES.slice();
if (!choices.includes(val)) {
choices.push(val);
}
const options = choices.map(s => ({ value: s, label: s }));
return (
<div className="m-b-5" key={field}>
{field.replace('__', '')}
<Select.Creatable
placeholder="Select"
options={options}
value={this.state.selectedValues[field]}
onChange={this.changeFilter.bind(this, field)}
dateFilter = (
<div className="row space-1">
<div className="col-lg-6 col-xs-12">
<DateFilterControl
name={since}
label="Since"
description="Select starting date"
onChange={this.changeFilter.bind(this, since)}
value={this.state.selectedValues[since]}
/>
</div>
);
});
<div className="col-lg-6 col-xs-12">
<DateFilterControl
name={until}
label="Until"
description="Select end date"
onChange={this.changeFilter.bind(this, until)}
value={this.state.selectedValues[until]}
/>
</div>
</div>
);
}
const datasourceFilters = [];
const sqlaFilters = [];
const druidFilters = [];
if (this.props.showSqlaTimeGrain) sqlaFilters.push('time_grain_sqla');
if (this.props.showSqlaTimeColumn) sqlaFilters.push('granularity_sqla');
if (this.props.showDruidTimeGrain) druidFilters.push('granularity');
if (this.props.showDruidTimeOrigin) druidFilters.push('druid_time_origin');
if (sqlaFilters.length) {
datasourceFilters.push(
<ControlRow
key="sqla-filters"
className="control-row"
controls={sqlaFilters.map(control => (
<Control {...this.getControlData(control)} />
))}
/>,
);
}
if (druidFilters.length) {
datasourceFilters.push(
<ControlRow
key="druid-filters"
className="control-row"
controls={druidFilters.map(control => (
<Control {...this.getControlData(control)} />
))}
/>,
);
}
// Add created options to filtersChoices, even though it doesn't exist,
// or these options will exist in query sql but invisible to end user.
@@ -126,19 +197,22 @@ class FilterBox extends React.Component {
);
});
return (
<div>
{dateFilter}
{filters}
{!this.props.instantFiltering &&
<Button
bsSize="small"
bsStyle="primary"
onClick={this.clickApply.bind(this)}
disabled={!this.state.hasChanged}
>
Apply
</Button>
}
<div className="scrollbar-container">
<div className="scrollbar-content">
{dateFilter}
{datasourceFilters}
{filters}
{!this.props.instantFiltering &&
<Button
bsSize="small"
bsStyle="primary"
onClick={this.clickApply.bind(this)}
disabled={!this.state.hasChanged}
>
Apply
</Button>
}
</div>
</div>
);
}
@@ -164,6 +238,10 @@ function filterBox(slice, payload) {
filtersChoices={filtersChoices}
onChange={slice.addFilter}
showDateFilter={fd.date_filter}
showSqlaTimeGrain={fd.show_sqla_time_granularity}
showSqlaTimeColumn={fd.show_sqla_time_column}
showDruidTimeGrain={fd.show_druid_time_granularity}
showDruidTimeOrigin={fd.show_druid_time_origin}
datasource={slice.datasource}
origSelectedValues={slice.getFilters() || {}}
instantFiltering={fd.instant_filtering}

View File

@@ -665,3 +665,31 @@ def get_celery_app(config):
return _celery_app
_celery_app = celery.Celery(config_source=config.get('CELERY_CONFIG'))
return _celery_app
def merge_extra_filters(form_data):
# extra_filters are temporary/contextual filters that are external
# to the slice definition. We use those for dynamic interactive
# filters like the ones emitted by the "Filter Box" visualization
if form_data.get('extra_filters'):
# __form and __to are special extra_filters that target time
# boundaries. The rest of extra_filters are simple
# [column_name in list_of_values]. `__` prefix is there to avoid
# potential conflicts with column that would be named `from` or `to`
if 'filters' not in form_data:
form_data['filters'] = []
date_options = {
'__from': 'since',
'__to': 'until',
'__time_col': 'granularity_sqla',
'__time_grain': 'time_grain_sqla',
'__time_origin': 'druid_time_origin',
'__granularity': 'granularity',
}
for filtr in form_data['extra_filters']:
if date_options.get(filtr['col']): # merge date options
if filtr.get('val'):
form_data[date_options[filtr['col']]] = filtr['val']
else:
form_data['filters'] += [filtr] # merge col filters
del form_data['extra_filters']

View File

@@ -35,7 +35,7 @@ from superset import (
sm, sql_lab, results_backend, security,
)
from superset.legacy import cast_form_data
from superset.utils import has_access, QueryStatus
from superset.utils import has_access, QueryStatus, merge_extra_filters
from superset.connectors.connector_registry import ConnectorRegistry
import superset.models.core as models
from superset.models.sql_lab import Query
@@ -1087,6 +1087,11 @@ class Superset(BaseSupersetView):
datasource_id,
datasource_type)
form_data['datasource'] = str(datasource_id) + '__' + datasource_type
# On explore, merge extra filters into the form data
merge_extra_filters(form_data)
standalone = request.args.get("standalone") == "true"
bootstrap_data = {
"can_add": slice_add_perm,

View File

@@ -30,7 +30,7 @@ from six import string_types, PY3
from dateutil import relativedelta as rdelta
from superset import app, utils, cache, get_manifest_file
from superset.utils import DTTM_ALIAS
from superset.utils import DTTM_ALIAS, merge_extra_filters
config = app.config
stats_logger = config.get('STATS_LOGGER')
@@ -124,10 +124,6 @@ class BaseViz(object):
df = df.fillna(fillna)
return df
def get_extra_filters(self):
extra_filters = self.form_data.get('extra_filters', [])
return {f['col']: f['val'] for f in extra_filters}
def query_obj(self):
"""Building a query object"""
form_data = self.form_data
@@ -144,29 +140,22 @@ class BaseViz(object):
groupby.remove(DTTM_ALIAS)
is_timeseries = True
# extra_filters are temporary/contextual filters that are external
# to the slice definition. We use those for dynamic interactive
# filters like the ones emitted by the "Filter Box" visualization
extra_filters = self.get_extra_filters()
# Add extra filters into the query form data
merge_extra_filters(form_data)
granularity = (
form_data.get("granularity") or form_data.get("granularity_sqla")
form_data.get("granularity") or
form_data.get("granularity_sqla")
)
limit = int(form_data.get("limit") or 0)
timeseries_limit_metric = form_data.get("timeseries_limit_metric")
row_limit = int(
form_data.get("row_limit") or config.get("ROW_LIMIT"))
row_limit = int(form_data.get("row_limit") or config.get("ROW_LIMIT"))
# default order direction
order_desc = form_data.get("order_desc", True)
# __form and __to are special extra_filters that target time
# boundaries. The rest of extra_filters are simple
# [column_name in list_of_values]. `__` prefix is there to avoid
# potential conflicts with column that would be named `from` or `to`
since = (
extra_filters.get('__from') or
form_data.get("since") or ''
)
since = form_data.get("since", "")
until = form_data.get("until", "now")
# Backward compatibility hack
since_words = since.split(' ')
@@ -176,7 +165,6 @@ class BaseViz(object):
from_dttm = utils.parse_human_datetime(since)
until = extra_filters.get('__to') or form_data.get("until", "now")
to_dttm = utils.parse_human_datetime(until)
if from_dttm and to_dttm and from_dttm > to_dttm:
raise Exception(_("From date cannot be larger than to date"))
@@ -195,16 +183,6 @@ class BaseViz(object):
'druid_time_origin': form_data.get("druid_time_origin", ''),
}
filters = form_data.get('filters', [])
for col, vals in self.get_extra_filters().items():
if not (col and vals) or col.startswith('__'):
continue
elif col in self.datasource.filterable_column_names:
# Quote values with comma to avoid conflict
filters += [{
'col': col,
'op': 'in',
'val': vals,
}]
d = {
'granularity': granularity,
'from_dttm': from_dttm,

View File

@@ -7,6 +7,7 @@ from superset.utils import (
parse_human_timedelta,
zlib_compress,
zlib_decompress_to_string,
merge_extra_filters,
datetime_f,
JSONEncodedDict,
validate_json,
@@ -15,7 +16,7 @@ from superset.utils import (
import unittest
import uuid
from mock import Mock, patch
from mock import patch
import numpy
@@ -61,6 +62,61 @@ class UtilsTestCase(unittest.TestCase):
got_str = zlib_decompress_to_string(blob)
self.assertEquals(json_str, got_str)
def test_merge_extra_filters(self):
# does nothing if no extra filters
form_data = {'A': 1, 'B': 2, 'c': 'test'}
expected = {'A': 1, 'B': 2, 'c': 'test'}
merge_extra_filters(form_data)
self.assertEquals(form_data, expected)
# does nothing if empty extra_filters
form_data = {'A': 1, 'B': 2, 'c': 'test', 'extra_filters': []}
expected = {'A': 1, 'B': 2, 'c': 'test', 'extra_filters': []}
merge_extra_filters(form_data)
self.assertEquals(form_data, expected)
# copy over extra filters into empty filters
form_data = {'extra_filters': [
{'col': 'a', 'op': 'in', 'val': 'someval'},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2']}
]}
expected = {'filters': [
{'col': 'a', 'op': 'in', 'val': 'someval'},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2']}
]}
merge_extra_filters(form_data)
self.assertEquals(form_data, expected)
# adds extra filters to existing filters
form_data = {'extra_filters': [
{'col': 'a', 'op': 'in', 'val': 'someval'},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2']}
], 'filters': [{'col': 'D', 'op': '!=', 'val': ['G1', 'g2']}]}
expected = {'filters': [
{'col': 'D', 'op': '!=', 'val': ['G1', 'g2']},
{'col': 'a', 'op': 'in', 'val': 'someval'},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2']},
]}
merge_extra_filters(form_data)
self.assertEquals(form_data, expected)
# adds extra filters to existing filters and sets time options
form_data = {'extra_filters': [
{'col': '__from', 'op': 'in', 'val': '1 year ago'},
{'col': '__to', 'op': 'in', 'val': None},
{'col': '__time_col', 'op': 'in', 'val': 'birth_year'},
{'col': '__time_grain', 'op': 'in', 'val': 'years'},
{'col': 'A', 'op': 'like', 'val': 'hello'},
{'col': '__time_origin', 'op': 'in', 'val': 'now'},
{'col': '__granularity', 'op': 'in', 'val': '90 seconds'},
]}
expected = {
'filters': [{'col': 'A', 'op': 'like', 'val': 'hello'}],
'since': '1 year ago',
'granularity_sqla': 'birth_year',
'time_grain_sqla': 'years',
'granularity': '90 seconds',
'druid_time_origin': 'now',
}
merge_extra_filters(form_data)
self.assertEquals(form_data, expected)
def test_datetime_f(self):
self.assertEquals(datetime_f(datetime(1990, 9, 21, 19, 11, 19, 626096)),
'<nobr>1990-09-21T19:11:19.626096</nobr>')