mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
[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:
committed by
Maxime Beauchemin
parent
ff268a7526
commit
7c936e7f60
@@ -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] = {};
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>')
|
||||
|
||||
Reference in New Issue
Block a user