diff --git a/superset/assets/javascripts/explorev2/actions/exploreActions.js b/superset/assets/javascripts/explorev2/actions/exploreActions.js index 0ee46bcc0f7cd..1d48975e8ad93 100644 --- a/superset/assets/javascripts/explorev2/actions/exploreActions.js +++ b/superset/assets/javascripts/explorev2/actions/exploreActions.js @@ -1,6 +1,5 @@ /* eslint camelcase: 0 */ const $ = window.$ = require('jquery'); - const FAVESTAR_BASE_URL = '/superset/favstar/slice'; export const SET_FIELD_OPTIONS = 'SET_FIELD_OPTIONS'; @@ -89,19 +88,9 @@ export function removeFilter(filter) { return { type: REMOVE_FILTER, filter }; } -export const CHANGE_FILTER_FIELD = 'CHANGE_FILTER_FIELD'; -export function changeFilterField(filter, field) { - return { type: CHANGE_FILTER_FIELD, filter, field }; -} - -export const CHANGE_FILTER_OP = 'CHANGE_FILTER_OP'; -export function changeFilterOp(filter, op) { - return { type: CHANGE_FILTER_OP, filter, op }; -} - -export const CHANGE_FILTER_VALUE = 'CHANGE_FILTER_VALUE'; -export function changeFilterValue(filter, value) { - return { type: CHANGE_FILTER_VALUE, filter, value }; +export const CHANGE_FILTER = 'CHANGE_FILTER'; +export function changeFilter(filter, field, value) { + return { type: CHANGE_FILTER, filter, field, value }; } export const SET_FIELD_VALUE = 'SET_FIELD_VALUE'; diff --git a/superset/assets/javascripts/explorev2/components/ControlPanelsContainer.jsx b/superset/assets/javascripts/explorev2/components/ControlPanelsContainer.jsx index d39640588b026..44c0fdd141f18 100644 --- a/superset/assets/javascripts/explorev2/components/ControlPanelsContainer.jsx +++ b/superset/assets/javascripts/explorev2/components/ControlPanelsContainer.jsx @@ -4,9 +4,10 @@ import { bindActionCreators } from 'redux'; import * as actions from '../actions/exploreActions'; import { connect } from 'react-redux'; import { Panel, Alert } from 'react-bootstrap'; -import { visTypes, sectionsToRender } from '../stores/store'; +import { visTypes, sectionsToRender, commonControlPanelSections } from '../stores/store'; import ControlPanelSection from './ControlPanelSection'; import FieldSetRow from './FieldSetRow'; +import Filters from './Filters'; const propTypes = { datasource_type: PropTypes.string.isRequired, @@ -44,6 +45,12 @@ class ControlPanelsContainer extends React.Component { return sectionsToRender(this.props.form_data.viz_type, this.props.datasource_type); } + filterSectionsToRender() { + const filterSections = this.props.datasource_type === 'table' ? + [commonControlPanelSections.filters[0]] : commonControlPanelSections.filters; + return filterSections; + } + fieldOverrides() { const viz = visTypes[this.props.form_data.viz_type]; return viz.fieldOverrides; @@ -86,7 +93,20 @@ class ControlPanelsContainer extends React.Component { ))} ))} - {/* TODO: add filters section */} + {this.filterSectionsToRender().map((section) => ( + + + + ))} } diff --git a/superset/assets/javascripts/explorev2/components/ExploreViewContainer.jsx b/superset/assets/javascripts/explorev2/components/ExploreViewContainer.jsx index 63b3974b30e6d..b3aafa736d01f 100644 --- a/superset/assets/javascripts/explorev2/components/ExploreViewContainer.jsx +++ b/superset/assets/javascripts/explorev2/components/ExploreViewContainer.jsx @@ -8,6 +8,8 @@ import ControlPanelsContainer from './ControlPanelsContainer'; import SaveModal from './SaveModal'; import QueryAndSaveBtns from '../../explore/components/QueryAndSaveBtns'; import { autoQueryFields } from '../stores/store'; +import { getParamObject } from '../../modules/utils.js'; + const $ = require('jquery'); const propTypes = { @@ -47,18 +49,7 @@ class ExploreViewContainer extends React.Component { } onQuery(form_data) { - const data = {}; - Object.keys(form_data).forEach((field) => { - // filter out null fields - if (form_data[field] !== null && field !== 'datasource') { - data[field] = form_data[field]; - } - }); - // V2 tag temporarily for updating url - // Todo: remove after launch - data.V2 = true; - data.datasource_id = this.props.form_data.datasource; - data.datasource_type = this.props.datasource_type; + const data = getParamObject(form_data, this.props.datasource_type); this.queryFormData(data); const params = $.param(data, true); diff --git a/superset/assets/javascripts/explorev2/components/Filter.jsx b/superset/assets/javascripts/explorev2/components/Filter.jsx new file mode 100644 index 0000000000000..ff6f38dd202b8 --- /dev/null +++ b/superset/assets/javascripts/explorev2/components/Filter.jsx @@ -0,0 +1,92 @@ +import React from 'react'; +// import { Tab, Row, Col, Nav, NavItem } from 'react-bootstrap'; +import Select from 'react-select'; +import { Button } from 'react-bootstrap'; + +const propTypes = { + actions: React.PropTypes.object.isRequired, + filterColumnOpts: React.PropTypes.array, + prefix: React.PropTypes.string, + filter: React.PropTypes.object.isRequired, +}; + +const defaultProps = { + filterColumnOpts: [], + prefix: 'flt', +}; + +export default class Filter extends React.Component { + constructor(props) { + super(props); + const opChoices = this.props.prefix === 'flt' ? + ['in', 'not in'] : ['==', '!=', '>', '<', '>=', '<=']; + this.state = { + opChoices, + }; + } + changeCol(filter, colOpt) { + const val = (colOpt) ? colOpt.value : null; + this.props.actions.changeFilter(filter, 'col', val); + } + changeOp(filter, opOpt) { + const val = (opOpt) ? opOpt.value : null; + this.props.actions.changeFilter(filter, 'op', val); + } + changeValue(filter, event) { + this.props.actions.changeFilter(filter, 'value', event.target.value); + } + removeFilter(filter) { + this.props.actions.removeFilter(filter); + } + render() { + return ( +
+
+ ({ value: o, label: o }))} + value={this.props.filter.op} + autosize={false} + onChange={this.changeOp.bind(this, this.props.filter)} + /> +
+ +
+
+ +
+
+
+ ); + } +} + +Filter.propTypes = propTypes; +Filter.defaultProps = defaultProps; diff --git a/superset/assets/javascripts/explorev2/components/Filters.jsx b/superset/assets/javascripts/explorev2/components/Filters.jsx index 13e465c6103bb..d9cbe7cb46c32 100644 --- a/superset/assets/javascripts/explorev2/components/Filters.jsx +++ b/superset/assets/javascripts/explorev2/components/Filters.jsx @@ -1,109 +1,63 @@ import React from 'react'; // import { Tab, Row, Col, Nav, NavItem } from 'react-bootstrap'; -import Select from 'react-select'; +import Filter from './Filter'; import { Button } from 'react-bootstrap'; import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import * as actions from '../actions/exploreActions'; import shortid from 'shortid'; const propTypes = { - actions: React.PropTypes.object, + actions: React.PropTypes.object.isRequired, filterColumnOpts: React.PropTypes.array, filters: React.PropTypes.array, + prefix: React.PropTypes.string, }; const defaultProps = { filterColumnOpts: [], filters: [], + prefix: 'flt', }; class Filters extends React.Component { - constructor(props) { - super(props); - this.state = { - opOpts: ['in', 'not in'], - }; - } - changeField(filter, fieldOpt) { - const val = (fieldOpt) ? fieldOpt.value : null; - this.props.actions.changeFilterField(filter, val); - } - changeOp(filter, opOpt) { - const val = (opOpt) ? opOpt.value : null; - this.props.actions.changeFilterOp(filter, val); - } - changeValue(filter, value) { - this.props.actions.changeFilterValue(filter, value); - } - removeFilter(filter) { - this.props.actions.removeFilter(filter); - } addFilter() { this.props.actions.addFilter({ id: shortid.generate(), - field: null, + prefix: this.props.prefix, + col: null, op: null, value: null, }); } render() { - const filters = this.props.filters.map((filter) => ( -
- ({ value: o, label: o }))} - value={filter.op} - autosize={false} - onChange={this.changeOp.bind(this, filter)} + const filters = []; + this.props.filters.forEach((filter) => { + // only display filters with current prefix + if (filter.prefix === this.props.prefix) { + filters.push( + -
- -
-
+ ); + } + }); + return ( +
+ {filters} +
+
- ) - ); - return ( -
-
Filters
-
- {filters} - -
-
); } } @@ -114,14 +68,9 @@ Filters.defaultProps = defaultProps; function mapStateToProps(state) { return { filterColumnOpts: state.filterColumnOpts, - filters: state.filters, - }; -} - -function mapDispatchToProps(dispatch) { - return { - actions: bindActionCreators(actions, dispatch), + filters: state.viz.form_data.filters, }; } -export default connect(mapStateToProps, mapDispatchToProps)(Filters); +export { Filters }; +export default connect(mapStateToProps, () => ({}))(Filters); diff --git a/superset/assets/javascripts/explorev2/components/SaveModal.js b/superset/assets/javascripts/explorev2/components/SaveModal.js index 08b960122db08..6c38b2f4237ef 100644 --- a/superset/assets/javascripts/explorev2/components/SaveModal.js +++ b/superset/assets/javascripts/explorev2/components/SaveModal.js @@ -4,6 +4,7 @@ import $ from 'jquery'; import { Modal, Alert, Button, Radio } from 'react-bootstrap'; import Select from 'react-select'; import { connect } from 'react-redux'; +import { getParamObject } from '../../modules/utils.js'; const propTypes = { can_edit: PropTypes.bool, @@ -57,10 +58,8 @@ class SaveModal extends React.Component { saveOrOverwrite(gotodash) { this.setState({ alert: null }); this.props.actions.removeSaveModalAlert(); - const params = {}; + const params = getParamObject(this.props.form_data, this.props.datasource_type); const sliceParams = {}; - params.datasource_id = this.props.form_data.datasource; - params.datasource_type = this.props.datasource_type; params.datasource_name = this.props.form_data.datasource_name; let sliceName = null; @@ -76,12 +75,6 @@ class SaveModal extends React.Component { sliceParams.slice_name = this.props.form_data.slice_name; } - Object.keys(this.props.form_data).forEach((field) => { - if (this.props.form_data[field] !== null && field !== 'slice_name') { - params[field] = this.props.form_data[field]; - } - }); - const addToDash = this.state.addToDash; sliceParams.add_to_dash = addToDash; let dashboard = null; @@ -105,7 +98,6 @@ class SaveModal extends React.Component { default: dashboard = null; } - params.V2 = true; sliceParams.goto_dash = gotodash; const baseUrl = '/superset/explore/' + `${this.props.datasource_type}/${this.props.form_data.datasource}/`; diff --git a/superset/assets/javascripts/explorev2/index.jsx b/superset/assets/javascripts/explorev2/index.jsx index 9819e18301f63..17a37831e578a 100644 --- a/superset/assets/javascripts/explorev2/index.jsx +++ b/superset/assets/javascripts/explorev2/index.jsx @@ -1,3 +1,4 @@ +/* eslint camelcase: 0 */ import React from 'react'; import ReactDOM from 'react-dom'; import ExploreViewContainer from './components/ExploreViewContainer'; @@ -28,6 +29,35 @@ const bootstrappedState = Object.assign(initialState(bootstrapData.viz.form_data bootstrappedState.viz.form_data.datasource = parseInt(bootstrapData.datasource_id, 10); bootstrappedState.viz.form_data.datasource_name = bootstrapData.datasource_name; +function parseFilters(form_data, prefix = 'flt') { + const filters = []; + for (let i = 0; i < 10; i++) { + if (form_data[`${prefix}_col_${i}`] && form_data[`${prefix}_op_${i}`]) { + filters.push({ + prefix, + col: form_data[`${prefix}_col_${i}`], + op: form_data[`${prefix}_op_${i}`], + value: form_data[`${prefix}_eq_${i}`], + }); + } + /* eslint no-param-reassign: 0 */ + delete form_data[`${prefix}_col_${i}`]; + delete form_data[`${prefix}_op_${i}`]; + delete form_data[`${prefix}_eq_${i}`]; + } + return filters; +} + +function getFilters(form_data, datasource_type) { + if (datasource_type === 'table') { + return parseFilters(form_data); + } + return parseFilters(form_data).concat(parseFilters(form_data, 'having')); +} + +bootstrappedState.viz.form_data.filters = + getFilters(bootstrappedState.viz.form_data, bootstrapData.datasource_type); + const store = createStore(exploreReducer, bootstrappedState, compose(applyMiddleware(thunk)) ); diff --git a/superset/assets/javascripts/explorev2/reducers/exploreReducer.js b/superset/assets/javascripts/explorev2/reducers/exploreReducer.js index 9b4d0edf7dfe3..cb57b1834e594 100644 --- a/superset/assets/javascripts/explorev2/reducers/exploreReducer.js +++ b/superset/assets/javascripts/explorev2/reducers/exploreReducer.js @@ -43,9 +43,12 @@ export const exploreReducer = function (state, action) { const fieldNames = Object.keys(optionsByFieldName); fieldNames.forEach((fieldName) => { - newState.fields[fieldName].choices = optionsByFieldName[fieldName]; + if (fieldName === 'filterable_cols') { + newState.filterColumnOpts = optionsByFieldName[fieldName]; + } else { + newState.fields[fieldName].choices = optionsByFieldName[fieldName]; + } }); - return Object.assign({}, state, newState); }, @@ -53,19 +56,32 @@ export const exploreReducer = function (state, action) { return Object.assign({}, state, { filterColumnOpts: action.filterColumnOpts }); }, [actions.ADD_FILTER]() { - return addToArr(state, 'filters', action.filter); + const newFormData = addToArr(state.viz.form_data, 'filters', action.filter); + const newState = Object.assign( + {}, + state, + { viz: Object.assign({}, state.viz, { form_data: newFormData }) } + ); + return newState; }, [actions.REMOVE_FILTER]() { - return removeFromArr(state, 'filters', action.filter); - }, - [actions.CHANGE_FILTER_FIELD]() { - return alterInArr(state, 'filters', action.filter, { field: action.field }); - }, - [actions.CHANGE_FILTER_OP]() { - return alterInArr(state, 'filters', action.filter, { op: action.op }); + const newFormData = removeFromArr(state.viz.form_data, 'filters', action.filter); + return Object.assign( + {}, + state, + { viz: Object.assign({}, state.viz, { form_data: newFormData }) } + ); }, - [actions.CHANGE_FILTER_VALUE]() { - return alterInArr(state, 'filters', action.filter, { value: action.value }); + [actions.CHANGE_FILTER]() { + const changes = {}; + changes[action.field] = action.value; + const newFormData = alterInArr( + state.viz.form_data, 'filters', action.filter, changes); + return Object.assign( + {}, + state, + { viz: Object.assign({}, state.viz, { form_data: newFormData }) } + ); }, [actions.SET_FIELD_VALUE]() { const newFormData = action.key === 'datasource' ? diff --git a/superset/assets/javascripts/explorev2/stores/store.js b/superset/assets/javascripts/explorev2/stores/store.js index 632b64fb563f7..cd70eff5291f4 100644 --- a/superset/assets/javascripts/explorev2/stores/store.js +++ b/superset/assets/javascripts/explorev2/stores/store.js @@ -105,6 +105,20 @@ export const commonControlPanelSections = { ], }, ], + filters: [ + { + label: 'Filters', + description: 'Filters are defined using comma delimited strings as in ' + + 'Leave the value field empty to filter empty strings or nulls' + + 'For filters with comma in values, wrap them in single quotes' + + "as in ", + }, + { + label: 'Result Filters', + description: 'The filters to apply after post-aggregation.' + + 'Leave the value field empty to filter empty strings or nulls', + }, + ], }; export const visTypes = { @@ -1688,6 +1702,7 @@ export function defaultFormData(vizType = 'table', datasourceType = 'table') { slice_name: null, slice_id: null, datasource_name: null, + filters: [], }; const sections = sectionsToRender(vizType, datasourceType); sections.forEach((section) => { @@ -1722,6 +1737,7 @@ export function initialState(vizType = 'table') { isDatasourceMetaLoading: false, datasources: null, datasource_type: null, + filterColumnOpts: [], fields, viz: defaultViz(vizType), isStarred: false, diff --git a/superset/assets/javascripts/modules/utils.js b/superset/assets/javascripts/modules/utils.js index 157aa0ea4f50b..98e4306647de8 100644 --- a/superset/assets/javascripts/modules/utils.js +++ b/superset/assets/javascripts/modules/utils.js @@ -1,3 +1,4 @@ +/* eslint camelcase: 0 */ const d3 = require('d3'); const $ = require('jquery'); @@ -152,3 +153,35 @@ export function slugify(string) { .replace(/[\s\W-]+/g, '-') // replace spaces, non-word chars, w/ a single dash (-) .replace(/-$/, ''); // remove last floating dash } + +function formatFilters(filters) { + // outputs an object of url params of filters + // prefix can be 'flt' or 'having' + const params = {}; + for (let i = 0; i < filters.length; i++) { + const filter = filters[i]; + params[`${filter.prefix}_col_${i + 1}`] = filter.col; + params[`${filter.prefix}_op_${i + 1}`] = filter.op; + params[`${filter.prefix}_eq_${i + 1}`] = filter.value; + } + return params; +} + +export function getParamObject(form_data, datasource_type) { + const data = { + // V2 tag temporarily for updating url + // Todo: remove after launch + V2: true, + datasource_id: form_data.datasource, + datasource_type, + }; + Object.keys(form_data).forEach((field) => { + // filter out null fields + if (form_data[field] !== null && field !== 'datasource') { + data[field] = form_data[field]; + } + }); + const filterParams = formatFilters(form_data.filters); + Object.assign(data, filterParams); + return data; +} diff --git a/superset/assets/spec/javascripts/explorev2/actions_spec.js b/superset/assets/spec/javascripts/explorev2/actions_spec.js index ceef46223884f..0b4cb2d6c0397 100644 --- a/superset/assets/spec/javascripts/explorev2/actions_spec.js +++ b/superset/assets/spec/javascripts/explorev2/actions_spec.js @@ -15,4 +15,15 @@ describe('reducers', () => { actions.setFieldValue('table', 'show_legend')); expect(newState.viz.form_data.show_legend).to.equal(false); }); + it('adds a filter given a new filter', () => { + const newState = exploreReducer(initialState('table'), + actions.addFilter({ + id: 1, + prefix: 'flt', + col: null, + op: null, + value: null, + })); + expect(newState.viz.form_data.filters).to.have.length(1); + }); }); diff --git a/superset/assets/spec/javascripts/explorev2/components/Filter_spec.js b/superset/assets/spec/javascripts/explorev2/components/Filter_spec.js new file mode 100644 index 0000000000000..9a55b59cdc51d --- /dev/null +++ b/superset/assets/spec/javascripts/explorev2/components/Filter_spec.js @@ -0,0 +1,41 @@ +/* eslint-disable no-unused-expressions */ +import React from 'react'; +import Select from 'react-select'; +import { Button } from 'react-bootstrap'; +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import { shallow } from 'enzyme'; +import Filter from '../../../../javascripts/explorev2/components/Filter'; + +const defaultProps = { + actions: {}, + filterColumnOpts: ['country_name'], + filter: { + id: 1, + prefix: 'flt', + col: 'country_name', + eq: 'in', + value: 'China', + }, + prefix: 'flt', +}; + +describe('Filter', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders Filters', () => { + expect( + React.isValidElement() + ).to.equal(true); + }); + + it('renders two select, one button and one input', () => { + expect(wrapper.find(Select)).to.have.lengthOf(2); + expect(wrapper.find(Button)).to.have.lengthOf(1); + expect(wrapper.find('input')).to.have.lengthOf(1); + }); +}); diff --git a/superset/assets/spec/javascripts/explorev2/components/Filters_spec.js b/superset/assets/spec/javascripts/explorev2/components/Filters_spec.js new file mode 100644 index 0000000000000..4074bc96e127e --- /dev/null +++ b/superset/assets/spec/javascripts/explorev2/components/Filters_spec.js @@ -0,0 +1,40 @@ +/* eslint-disable no-unused-expressions */ +import React from 'react'; +import { Button } from 'react-bootstrap'; +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import { shallow } from 'enzyme'; +import { Filters } from '../../../../javascripts/explorev2/components/Filters'; +import Filter from '../../../../javascripts/explorev2/components/Filter'; + +const defaultProps = { + filterColumnOpts: ['country_name'], + filters: [ + { + id: 1, + prefix: 'flt', + col: 'country_name', + eq: 'in', + value: 'China', + }], + prefix: 'flt', +}; + +describe('Filters', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders Filters', () => { + expect( + React.isValidElement() + ).to.equal(true); + }); + + it('renders one filter', () => { + expect(wrapper.find(Filter)).to.have.lengthOf(1); + expect(wrapper.find(Button)).to.have.lengthOf(1); + }); +}); diff --git a/superset/views.py b/superset/views.py index 277e070933512..5614209bbcf0d 100755 --- a/superset/views.py +++ b/superset/views.py @@ -2401,6 +2401,7 @@ def fetch_datasource_metadata(self): 'size': datasource.metrics_combo, 'mapbox_label': all_cols, 'point_radius': [(c, c) for c in (["Auto"] + datasource.column_names)], + 'filterable_cols': datasource.filterable_column_names, } return Response( diff --git a/tests/core_tests.py b/tests/core_tests.py index c6e13153da6fe..d5de2cc31ca7c 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -430,7 +430,7 @@ def test_fetch_datasource_metadata(self): url = '/superset/fetch_datasource_metadata?datasource_type=table&' \ 'datasource_id=1' resp = json.loads(self.get_resp(url)) - self.assertEqual(len(resp['field_options']), 20) + self.assertEqual(len(resp['field_options']), 21) def test_fetch_all_tables(self): self.login(username='admin')