diff --git a/superset/assets/spec/javascripts/components/FormRow_spec.jsx b/superset/assets/spec/javascripts/components/FormRow_spec.jsx
new file mode 100644
index 0000000000000..d30fc71da83c4
--- /dev/null
+++ b/superset/assets/spec/javascripts/components/FormRow_spec.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { Col, Row } from 'react-bootstrap';
+import TextControl from '../../../src/explore/components/controls/TextControl';
+import InfoTooltipWithTrigger from '../../../src/components/InfoTooltipWithTrigger';
+import FormRow from '../../../src/components/FormRow';
+
+const defaultProps = {
+ label: 'Hello',
+ tooltip: 'A tooltip',
+ control: ,
+};
+
+describe('FormRow', () => {
+ let wrapper;
+
+ const getWrapper = (overrideProps = {}) => {
+ const props = {
+ ...defaultProps,
+ ...overrideProps,
+ };
+ return shallow();
+ };
+
+ beforeEach(() => {
+ wrapper = getWrapper();
+ });
+
+ it('renders an InfoTooltipWithTrigger only if needed', () => {
+ expect(wrapper.find(InfoTooltipWithTrigger)).toHaveLength(1);
+ wrapper = getWrapper({ tooltip: null });
+ expect(wrapper.find(InfoTooltipWithTrigger)).toHaveLength(0);
+ });
+
+ it('renders a Row and 2 Cols', () => {
+ expect(wrapper.find(Row)).toHaveLength(1);
+ expect(wrapper.find(Col)).toHaveLength(2);
+ });
+
+});
diff --git a/superset/assets/spec/javascripts/explore/components/FilterBoxItemControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/FilterBoxItemControl_spec.jsx
new file mode 100644
index 0000000000000..1fd6bd8d2bab7
--- /dev/null
+++ b/superset/assets/spec/javascripts/explore/components/FilterBoxItemControl_spec.jsx
@@ -0,0 +1,37 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import sinon from 'sinon';
+import { shallow } from 'enzyme';
+import { OverlayTrigger } from 'react-bootstrap';
+
+import FilterBoxItemControl from '../../../../src/explore/components/controls/FilterBoxItemControl';
+import FormRow from '../../../../src/components/FormRow';
+import datasources from '../../../fixtures/mockDatasource';
+
+const defaultProps = {
+ datasource: datasources['7__table'],
+ onChange: sinon.spy(),
+};
+
+describe('FilterBoxItemControl', () => {
+ let wrapper;
+ let inst;
+
+ const getWrapper = (propOverrides) => {
+ const props = { ...defaultProps, ...propOverrides };
+ return shallow();
+ };
+ beforeEach(() => {
+ wrapper = getWrapper();
+ inst = wrapper.instance();
+ });
+
+ it('renders an OverlayTrigger', () => {
+ expect(wrapper.find(OverlayTrigger)).toHaveLength(1);
+ });
+
+ it('renderForms does the job', () => {
+ const popover = shallow(inst.renderForm());
+ expect(popover.find(FormRow)).toHaveLength(7);
+ });
+});
diff --git a/superset/assets/src/chart/ChartRenderer.jsx b/superset/assets/src/chart/ChartRenderer.jsx
index 5730ff9b0cdb7..20fae477fc40b 100644
--- a/superset/assets/src/chart/ChartRenderer.jsx
+++ b/superset/assets/src/chart/ChartRenderer.jsx
@@ -38,7 +38,7 @@ const defaultProps = {
triggerRender: false,
};
-class ChartRenderer extends React.PureComponent {
+class ChartRenderer extends React.Component {
constructor(props) {
super(props);
this.state = {};
diff --git a/superset/assets/src/components/FormRow.jsx b/superset/assets/src/components/FormRow.jsx
new file mode 100644
index 0000000000000..2365d85ee99a8
--- /dev/null
+++ b/superset/assets/src/components/FormRow.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Row, Col } from 'react-bootstrap';
+
+import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
+
+const STYLE_ROW = { marginTop: '5px', minHeight: '30px' };
+const STYLE_RALIGN = { textAlign: 'right' };
+
+const propTypes = {
+ label: PropTypes.string.isRequired,
+ tooltip: PropTypes.string,
+ control: PropTypes.node.isRequired,
+ isCheckbox: PropTypes.bool,
+};
+
+const defaultProps = {
+ tooltip: null,
+ isCheckbox: false,
+};
+
+export default function FormRow({ label, tooltip, control, isCheckbox }) {
+ const labelAndTooltip = (
+
+ {label}{' '}
+ {tooltip &&
+ }
+ );
+ if (isCheckbox) {
+ return (
+
+ {control}
+ {labelAndTooltip}
+
);
+ }
+ return (
+
+ {labelAndTooltip}
+ {control}
+
);
+}
+FormRow.propTypes = propTypes;
+FormRow.defaultProps = defaultProps;
diff --git a/superset/assets/src/explore/components/controls/CollectionControl.css b/superset/assets/src/explore/components/controls/CollectionControl.css
new file mode 100644
index 0000000000000..c43f93897ac2c
--- /dev/null
+++ b/superset/assets/src/explore/components/controls/CollectionControl.css
@@ -0,0 +1,3 @@
+.CollectionControl .list-group-item i.fa {
+ padding-top: 5px;
+}
diff --git a/superset/assets/src/explore/components/controls/CollectionControl.jsx b/superset/assets/src/explore/components/controls/CollectionControl.jsx
index b545072bb37cc..bd78f4781003e 100644
--- a/superset/assets/src/explore/components/controls/CollectionControl.jsx
+++ b/superset/assets/src/explore/components/controls/CollectionControl.jsx
@@ -9,6 +9,7 @@ import {
import InfoTooltipWithTrigger from '../../../components/InfoTooltipWithTrigger';
import ControlHeader from '../ControlHeader';
import controlMap from './';
+import './CollectionControl.css';
const propTypes = {
name: PropTypes.string.isRequired,
@@ -82,6 +83,7 @@ export default class CollectionControl extends React.Component {
@@ -101,7 +103,7 @@ export default class CollectionControl extends React.Component {
}
render() {
return (
-
+
{this.renderList()}
{},
+ asc: true,
+ clearable: true,
+ multiple: true,
+};
+
+const STYLE_WIDTH = { width: 350 };
+
+export default class FilterBoxItemControl extends React.Component {
+ constructor(props) {
+ super(props);
+ const { column, metric, asc, clearable, multiple, defaultValue } = props;
+ const state = { column, metric, asc, clearable, multiple, defaultValue };
+ this.state = state;
+ this.onChange = this.onChange.bind(this);
+ this.onControlChange = this.onControlChange.bind(this);
+ }
+ onChange() {
+ this.props.onChange(this.state);
+ }
+ onControlChange(attr, value) {
+ this.setState({ [attr]: value }, this.onChange);
+ }
+ setType() {
+ }
+ textSummary() {
+ return this.state.column || 'N/A';
+ }
+ renderForm() {
+ return (
+
+ ({
+ value: col.column_name,
+ label: col.column_name,
+ }))}
+ onChange={v => this.onControlChange('column', v)}
+ />
+ }
+ />
+ this.onControlChange('label', v)}
+ />
+ }
+ />
+ this.onControlChange('defaultValue', v)}
+ />
+ }
+ />
+ ({
+ value: m.metric_name,
+ label: m.metric_name,
+ }))}
+ onChange={v => this.onControlChange('metric', v)}
+ />
+ }
+ />
+ this.onControlChange('asc', v)}
+ />
+ }
+ />
+ this.onControlChange('multiple', v)}
+ />
+ }
+ />
+ this.onControlChange('clearable', !v)}
+ />
+ }
+ />
+
);
+ }
+ renderPopover() {
+ return (
+
+
+ {this.renderForm()}
+
+
+ );
+ }
+ render() {
+ return (
+
+ {this.textSummary()}{' '}
+
+
+
+
+ );
+ }
+}
+
+FilterBoxItemControl.propTypes = propTypes;
+FilterBoxItemControl.defaultProps = defaultProps;
diff --git a/superset/assets/src/explore/components/controls/index.js b/superset/assets/src/explore/components/controls/index.js
index 76ebf4ea88008..953b3b4764cf2 100644
--- a/superset/assets/src/explore/components/controls/index.js
+++ b/superset/assets/src/explore/components/controls/index.js
@@ -20,6 +20,7 @@ import VizTypeControl from './VizTypeControl';
import MetricsControl from './MetricsControl';
import AdhocFilterControl from './AdhocFilterControl';
import FilterPanel from './FilterPanel';
+import FilterBoxItemControl from './FilterBoxItemControl';
const controlMap = {
AnnotationLayerControl,
@@ -44,5 +45,6 @@ const controlMap = {
MetricsControl,
AdhocFilterControl,
FilterPanel,
+ FilterBoxItemControl,
};
export default controlMap;
diff --git a/superset/assets/src/explore/controlPanels/FilterBox.js b/superset/assets/src/explore/controlPanels/FilterBox.jsx
similarity index 52%
rename from superset/assets/src/explore/controlPanels/FilterBox.js
rename to superset/assets/src/explore/controlPanels/FilterBox.jsx
index 529fbe2ad1896..f987eb2a64920 100644
--- a/superset/assets/src/explore/controlPanels/FilterBox.js
+++ b/superset/assets/src/explore/controlPanels/FilterBox.jsx
@@ -1,29 +1,27 @@
+import React from 'react';
import { t } from '@superset-ui/translation';
export default {
controlPanelSections: [
{
- label: t('Query'),
+ label: t('Filters Configuration'),
expanded: true,
controlSetRows: [
- ['groupby'],
- ['metric'],
- ['adhoc_filters'],
+ ['filter_configs'],
+ [
],
['date_filter', 'instant_filtering'],
['show_sqla_time_granularity', 'show_sqla_time_column'],
['show_druid_time_granularity', 'show_druid_time_origin'],
+ ['adhoc_filters'],
],
},
],
controlOverrides: {
- groupby: {
- label: t('Filter controls'),
+ adhoc_filters: {
+ label: t('Global Filters'),
description: t(
- 'The controls you want to filter on. Note that only columns ' +
- 'checked as "filterable" will show up on this list.'),
- mapStateToProps: state => ({
- options: (state.datasource) ? state.datasource.columns.filter(c => c.filterable) : [],
- }),
+ 'These filters, like the time filters, will be applied ' +
+ 'to each individual filters as the values are populated.'),
},
},
};
diff --git a/superset/assets/src/explore/controls.jsx b/superset/assets/src/explore/controls.jsx
index 3beed8a553b7f..f0efeef8c9184 100644
--- a/superset/assets/src/explore/controls.jsx
+++ b/superset/assets/src/explore/controls.jsx
@@ -2289,6 +2289,15 @@ export const controls = {
default: true,
},
+ filter_configs: {
+ type: 'CollectionControl',
+ label: 'Filters',
+ description: t('Filter configuration for the filter box'),
+ validators: [v.nonEmpty],
+ controlName: 'FilterBoxItemControl',
+ mapStateToProps: ({ datasource }) => ({ datasource }),
+ },
+
normalized: {
type: 'CheckboxControl',
label: t('Normalized'),
diff --git a/superset/assets/src/visualizations/FilterBox/FilterBox.jsx b/superset/assets/src/visualizations/FilterBox/FilterBox.jsx
index f5a63f28021be..79f109342cecb 100644
--- a/superset/assets/src/visualizations/FilterBox/FilterBox.jsx
+++ b/superset/assets/src/visualizations/FilterBox/FilterBox.jsx
@@ -170,9 +170,8 @@ class FilterBox extends React.Component {
}
return datasourceFilters;
}
-
- renderFilters() {
- const { filtersFields, filtersChoices } = this.props;
+ renderSelect(filterConfig) {
+ const { filtersChoices } = this.props;
const { selectedValues } = this.state;
// Add created options to filtersChoices, even though it doesn't exist,
@@ -196,35 +195,55 @@ class FilterBox extends React.Component {
});
});
});
+ const { key, label } = filterConfig;
+ const data = this.props.filtersChoices[key];
+ const max = Math.max(...data.map(d => d.metric));
+ let value = selectedValues[key] || null;
+
+ // Assign default value if required
+ if (!value && filterConfig.defaultValue) {
+ if (filterConfig.multiple) {
+ // Support for semicolon-delimited multiple values
+ value = filterConfig.defaultValue.split(';');
+ } else {
+ value = filterConfig.defaultValue;
+ }
+ }
+ return (
+ {
+ const perc = Math.round((opt.metric / max) * 100);
+ const backgroundImage = (
+ 'linear-gradient(to right, lightgrey, ' +
+ `lightgrey ${perc}%, rgba(0,0,0,0) ${perc}%`
+ );
+ const style = {
+ backgroundImage,
+ padding: '2px 5px',
+ };
+ return { value: opt.id, label: opt.id, style };
+ })}
+ onChange={(...args) => { this.changeFilter(key, ...args); }}
+ selectComponent={Creatable}
+ selectWrap={VirtualizedSelect}
+ optionRenderer={VirtualizedRendererWrap(opt => opt.label)}
+ />);
+ }
+
+ renderFilters() {
- return filtersFields.map(({ key, label }) => {
- const data = filtersChoices[key];
- const max = Math.max(...data.map(d => d.metric));
+ const { filtersFields } = this.props;
+ return filtersFields.map((filterConfig) => {
+ const { label, key } = filterConfig;
return (
{label}
- {
- const perc = Math.round((opt.metric / max) * 100);
- const backgroundImage = (
- 'linear-gradient(to right, lightgrey, ' +
- `lightgrey ${perc}%, rgba(0,0,0,0) ${perc}%`
- );
- const style = {
- backgroundImage,
- padding: '2px 5px',
- };
- return { value: opt.id, label: opt.id, style };
- })}
- onChange={(...args) => { this.changeFilter(key, ...args); }}
- selectComponent={Creatable}
- selectWrap={VirtualizedSelect}
- optionRenderer={VirtualizedRendererWrap(opt => opt.label)}
- />
+ {this.renderSelect(filterConfig)}
);
});
diff --git a/superset/assets/src/visualizations/FilterBox/transformProps.js b/superset/assets/src/visualizations/FilterBox/transformProps.js
index f846c40eb31b1..b09f730359d6c 100644
--- a/superset/assets/src/visualizations/FilterBox/transformProps.js
+++ b/superset/assets/src/visualizations/FilterBox/transformProps.js
@@ -9,7 +9,7 @@ export default function transformProps(chartProps) {
} = chartProps;
const {
dateFilter,
- groupby,
+ filterConfigs,
instantFiltering,
showDruidTimeGranularity,
showDruidTimeOrigin,
@@ -18,9 +18,10 @@ export default function transformProps(chartProps) {
} = formData;
const { verboseMap } = datasource;
- const filtersFields = groupby.map(key => ({
- key,
- label: verboseMap[key] || key,
+ const filtersFields = filterConfigs.map(flt => ({
+ ...flt,
+ key: flt.column,
+ label: flt.label || verboseMap[flt.column] || flt.column,
}));
return {
diff --git a/superset/data/world_bank.py b/superset/data/world_bank.py
index b75a079467bca..fa66a3830c724 100644
--- a/superset/data/world_bank.py
+++ b/superset/data/world_bank.py
@@ -86,7 +86,23 @@ def load_world_bank_health_n_pop():
defaults,
viz_type='filter_box',
date_filter=False,
- groupby=['region', 'country_name'])),
+ filter_configs=[
+ {
+ 'asc': False,
+ 'clearable': True,
+ 'column': 'region',
+ 'key': '2s98dfu',
+ 'metric': 'sum__SP_POP_TOTL',
+ 'multiple': True,
+ }, {
+ 'asc': False,
+ 'clearable': True,
+ 'key': 'li3j2lk',
+ 'column': 'country_name',
+ 'metric': 'sum__SP_POP_TOTL',
+ 'multiple': True,
+ },
+ ])),
Slice(
slice_name="World's Population",
viz_type='big_number',
diff --git a/superset/migrations/versions/fb13d49b72f9_better_filters.py b/superset/migrations/versions/fb13d49b72f9_better_filters.py
new file mode 100644
index 0000000000000..4d12627d0f646
--- /dev/null
+++ b/superset/migrations/versions/fb13d49b72f9_better_filters.py
@@ -0,0 +1,84 @@
+"""better_filters
+
+Revision ID: fb13d49b72f9
+Revises: 6c7537a6004a
+Create Date: 2018-12-11 22:03:21.612516
+
+"""
+import json
+import logging
+
+from alembic import op
+from sqlalchemy import Column, Integer, String, Text
+from sqlalchemy.ext.declarative import declarative_base
+
+from superset import db
+
+# revision identifiers, used by Alembic.
+revision = 'fb13d49b72f9'
+down_revision = 'de021a1ca60d'
+
+Base = declarative_base()
+
+
+class Slice(Base):
+ __tablename__ = 'slices'
+
+ id = Column(Integer, primary_key=True)
+ params = Column(Text)
+ viz_type = Column(String(250))
+ slice_name = Column(String(250))
+
+
+def upgrade():
+ bind = op.get_bind()
+ session = db.Session(bind=bind)
+
+ filter_box_slices = session.query(Slice).filter_by(viz_type='filter_box')
+ for slc in filter_box_slices.all():
+ try:
+ params = json.loads(slc.params)
+ logging.info(f'Upgrading {slc.slice_name}')
+ cols = params.get('groupby')
+ metrics = params.get('metrics')
+ if cols:
+ flts = [{
+ 'column': col,
+ 'metric': metrics[0] if metrics else None,
+ 'asc': False,
+ 'clearable': True,
+ 'multiple': True,
+ } for col in cols]
+ params['filter_configs'] = flts
+ if 'groupby' in params:
+ del params['groupby']
+ if 'metrics' in params:
+ del params['metrics']
+ slc.params = json.dumps(params, sort_keys=True)
+ except Exception as e:
+ logging.exception(e)
+
+ session.commit()
+ session.close()
+
+
+def downgrade():
+ bind = op.get_bind()
+ session = db.Session(bind=bind)
+
+ filter_box_slices = session.query(Slice).filter_by(viz_type='filter_box')
+ for slc in filter_box_slices.all():
+ try:
+ params = json.loads(slc.params)
+ logging.info(f'Downgrading {slc.slice_name}')
+ flts = params.get('filter_configs')
+ if not flts:
+ continue
+ params['metrics'] = [flts[0].get('metric')]
+ params['groupby'] = [o.get('column') for o in flts]
+ slc.params = json.dumps(params, sort_keys=True)
+ except Exception as e:
+ logging.exception(e)
+
+ session.commit()
+ session.close()
diff --git a/superset/viz.py b/superset/viz.py
index 55a4e8a361cb6..9318cb730bc94 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -1785,40 +1785,49 @@ class FilterBoxViz(BaseViz):
is_timeseries = False
credits = 'a Superset original'
cache_type = 'get_data'
+ filter_row_limit = 1000
def query_obj(self):
return None
def run_extra_queries(self):
- qry = self.filter_query_obj()
- filters = [g for g in self.form_data['groupby']]
+ qry = super(FilterBoxViz, self).query_obj()
+ filters = self.form_data.get('filter_configs') or []
+ qry['row_limit'] = self.filter_row_limit
self.dataframes = {}
for flt in filters:
- qry['groupby'] = [flt]
+ col = flt.get('column')
+ if not col:
+ raise Exception(_(
+ 'Invalid filter configuration, please select a column'))
+ qry['groupby'] = [col]
+ metric = flt.get('metric')
+ qry['metrics'] = [metric] if metric else []
df = self.get_df_payload(query_obj=qry).get('df')
- self.dataframes[flt] = df
-
- def filter_query_obj(self):
- qry = super(FilterBoxViz, self).query_obj()
- groupby = self.form_data.get('groupby')
- if len(groupby) < 1 and not self.form_data.get('date_filter'):
- raise Exception(_('Pick at least one filter field'))
- qry['metrics'] = [
- self.form_data['metric']]
- return qry
+ self.dataframes[col] = df
def get_data(self, df):
+ filters = self.form_data.get('filter_configs') or []
d = {}
- filters = [g for g in self.form_data['groupby']]
for flt in filters:
- df = self.dataframes[flt]
- d[flt] = [{
- 'id': row[0],
- 'text': row[0],
- 'filter': flt,
- 'metric': row[1]}
- for row in df.itertuples(index=False)
- ]
+ col = flt.get('column')
+ metric = flt.get('metric')
+ df = self.dataframes.get(col)
+ if metric:
+ df = df.sort_values(metric, ascending=flt.get('asc'))
+ d[col] = [{
+ 'id': row[0],
+ 'text': row[0],
+ 'metric': row[1]}
+ for row in df.itertuples(index=False)
+ ]
+ else:
+ df = df.sort_values(col, ascending=flt.get('asc'))
+ d[col] = [{
+ 'id': row[0],
+ 'text': row[0]}
+ for row in df.itertuples(index=False)
+ ]
return d