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