diff --git a/superset/assets/javascripts/dashboard/Dashboard.jsx b/superset/assets/javascripts/dashboard/Dashboard.jsx index 5d150a20bc4..f1334245d8c 100644 --- a/superset/assets/javascripts/dashboard/Dashboard.jsx +++ b/superset/assets/javascripts/dashboard/Dashboard.jsx @@ -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] = {}; } diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx index 57222a6efcf..cd5d38a1b35 100644 --- a/superset/assets/javascripts/explore/stores/controls.jsx +++ b/superset/assets/javascripts/explore/stores/controls.jsx @@ -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'), diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js index 1d4d79b5b47..ca8a63f20bf 100644 --- a/superset/assets/javascripts/explore/stores/visTypes.js +++ b/superset/assets/javascripts/explore/stores/visTypes.js @@ -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'], ], }, ], diff --git a/superset/assets/javascripts/modules/superset.js b/superset/assets/javascripts/modules/superset.js index 6570abad606..9d58f02e949 100644 --- a/superset/assets/javascripts/modules/superset.js +++ b/superset/assets/javascripts/modules/superset.js @@ -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(); diff --git a/superset/assets/visualizations/filter_box.css b/superset/assets/visualizations/filter_box.css index e1b72f3bd77..b6938e8ee0b 100644 --- a/superset/assets/visualizations/filter_box.css +++ b/superset/assets/visualizations/filter_box.css @@ -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; diff --git a/superset/assets/visualizations/filter_box.jsx b/superset/assets/visualizations/filter_box.jsx index e465af56cc1..9605830ab2c 100644 --- a/superset/assets/visualizations/filter_box.jsx +++ b/superset/assets/visualizations/filter_box.jsx @@ -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 ( -
- {field.replace('__', '')} - +
+
- ); - }); +
+ +
+
+ ); + } + 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( + ( + + ))} + />, + ); + } + if (druidFilters.length) { + datasourceFilters.push( + ( + + ))} + />, + ); } // 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 ( -
- {dateFilter} - {filters} - {!this.props.instantFiltering && - - } +
+
+ {dateFilter} + {datasourceFilters} + {filters} + {!this.props.instantFiltering && + + } +
); } @@ -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} diff --git a/superset/utils.py b/superset/utils.py index 956575b460a..fa72971b70d 100644 --- a/superset/utils.py +++ b/superset/utils.py @@ -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'] diff --git a/superset/views/core.py b/superset/views/core.py index 8cd3aac6e39..57710bbaa5c 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -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, diff --git a/superset/viz.py b/superset/viz.py index 2283e8daacc..953d8157683 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -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, diff --git a/tests/utils_tests.py b/tests/utils_tests.py index 1e40d92c2e8..b642c4374a7 100644 --- a/tests/utils_tests.py +++ b/tests/utils_tests.py @@ -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)), '1990-09-21T19:11:19.626096')