diff --git a/superset/assets/spec/javascripts/explore/components/StepsControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/StepsControl_spec.jsx new file mode 100644 index 0000000000000..87a3de8ed4a03 --- /dev/null +++ b/superset/assets/spec/javascripts/explore/components/StepsControl_spec.jsx @@ -0,0 +1,94 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/* eslint-disable no-unused-expressions */ +import React from 'react'; +import configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { shallow } from 'enzyme'; +import { ListGroupItem } from 'react-bootstrap'; + +import chartQueries from '../../dashboard/fixtures/mockChartQueries'; +import StepsControl from '../../../../src/explore/components/controls/StepsControl'; +import Button from '../../../../src/components/Button'; + +describe('StepsControl', () => { + const middlewares = [thunk]; + const mockStore = configureStore(middlewares); + const initialState = { + charts: { 0: {} }, + explore: { + can_overwrite: null, + user_id: '1', + datasource: {}, + slice: null, + controls: { + viz_type: { + value: 'funnel', + }, + }, + }, + selectedValues: {}, + }; + const store = mockStore(initialState); + + const defaultProps = { + name: 'steps', + label: 'Steps', + value: {}, + origSelectedValues: {}, + vizType: '', + annotationError: {}, + annotationQuery: {}, + onChange: () => {}, + charts: chartQueries, + }; + + const getWrapper = () => + shallow(, { + context: { store }, + }).dive(); + + it('renders Add Step button and Absolute filter', () => { + const wrapper = getWrapper(); + expect(wrapper.find(ListGroupItem)).toHaveLength(1); + }); + + it('add/remove Step', () => { + const wrapper = getWrapper(); + const label = wrapper.find(ListGroupItem).first(); + label.simulate('click'); + setTimeout(() => { + expect(wrapper.find('.list-group')).toHaveLength(1); + expect(wrapper.find('.metrics-select')).toHaveLength(2); + expect(wrapper.find(Button)).toHaveLength(1); + expect(wrapper.find(Button)).first().simulate('click'); + setTimeout(() => { + expect(wrapper.find('list-group')).toHaveLength(0); + }, 10); + }, 10); + }); + + it('onChange', () => { + const wrapper = getWrapper(); + + wrapper.instance().onChange(0, 'testControl', { test: true }); + expect(wrapper.state().selectedValues).toMatchObject({ 0: { testControl: { test: true } } }); + + }); +}); diff --git a/superset/assets/src/explore/components/controls/Filter.jsx b/superset/assets/src/explore/components/controls/Filter.jsx new file mode 100644 index 0000000000000..ad0b1e97d5642 --- /dev/null +++ b/superset/assets/src/explore/components/controls/Filter.jsx @@ -0,0 +1,204 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import PropTypes from 'prop-types'; +import Select from 'react-select'; +import { Button, Row, Col } from 'react-bootstrap'; +import { t } from '@superset-ui/translation'; +import SelectControl from './SelectControl'; + +const operatorsArr = [ + { val: 'in', type: 'array', useSelect: true, multi: true }, + { val: 'not in', type: 'array', useSelect: true, multi: true }, + { val: '==', type: 'string', useSelect: true, multi: false, havingOnly: true }, + { val: '!=', type: 'string', useSelect: true, multi: false, havingOnly: true }, + { val: '>=', type: 'string', havingOnly: true }, + { val: '<=', type: 'string', havingOnly: true }, + { val: '>', type: 'string', havingOnly: true }, + { val: '<', type: 'string', havingOnly: true }, + { val: 'regex', type: 'string', datasourceTypes: ['druid'] }, + { val: 'LIKE', type: 'string', datasourceTypes: ['table'] }, + { val: 'IS NULL', type: null }, + { val: 'IS NOT NULL', type: null }, +]; +const operators = {}; +operatorsArr.forEach((op) => { + operators[op.val] = op; +}); + +const propTypes = { + changeFilter: PropTypes.func, + removeFilter: PropTypes.func, + filter: PropTypes.object.isRequired, + datasource: PropTypes.object, + having: PropTypes.bool, + valuesLoading: PropTypes.bool, + valueChoices: PropTypes.array, +}; + +const defaultProps = { + changeFilter: () => {}, + removeFilter: () => {}, + datasource: null, + having: false, + valuesLoading: false, + valueChoices: [], +}; + +export default class Filter extends React.Component { + + switchFilterValue(prevOp, nextOp) { + if (operators[prevOp].type !== operators[nextOp].type) { + // Switch from array to string or vice versa + const val = this.props.filter.val; + let newVal; + if (operators[nextOp].type === 'string') { + if (!val || !val.length) { + newVal = ''; + } else { + newVal = val[0]; + } + } else if (operators[nextOp].type === 'array') { + if (!val || !val.length) { + newVal = []; + } else { + newVal = [val]; + } + } + this.props.changeFilter(['val', 'op'], [newVal, nextOp]); + } else { + // No value type change + this.props.changeFilter('op', nextOp); + } + } + + changeText(event) { + this.props.changeFilter('val', event.target.value); + } + + changeSelect(value) { + this.props.changeFilter('val', value); + } + + changeColumn(event) { + this.props.changeFilter('col', event.value); + } + + changeOp(event) { + this.switchFilterValue(this.props.filter.op, event.value); + } + + removeFilter(filter) { + this.props.removeFilter(filter); + } + + renderFilterFormControl(filter) { + const operator = operators[filter.op]; + if (operator.type === null) { + // IS NULL or IS NOT NULL + return null; + } + if (operator.useSelect && !this.props.having) { + // TODO should use a simple Select, not a control here... + return ( + + ); + } + return ( + + ); + } + render() { + const { datasource, filter } = this.props; + const opsChoices = operatorsArr + .filter((o) => { + if (this.props.having) { + return !!o.havingOnly; + } + return (!o.datasourceTypes || o.datasourceTypes.indexOf(datasource.type) >= 0); + }) + .map(o => ({ value: o.val, label: o.val })); + let colChoices; + if (datasource) { + if (this.props.having) { + colChoices = datasource.metrics_combo.map(c => ({ value: c[0], label: c[1] })); + } else { + colChoices = datasource.filterable_cols.map(c => ({ value: c[0], label: c[1] })); + } + } + return ( +
+ + + + + + {this.renderFilterFormControl(filter)} + + + + + +
+ ); + } +} + +Filter.propTypes = propTypes; +Filter.defaultProps = defaultProps; diff --git a/superset/assets/src/explore/components/controls/FilterControl.jsx b/superset/assets/src/explore/components/controls/FilterControl.jsx new file mode 100644 index 0000000000000..efe7032891d7c --- /dev/null +++ b/superset/assets/src/explore/components/controls/FilterControl.jsx @@ -0,0 +1,174 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button, Row, Col } from 'react-bootstrap'; +import { t } from '@superset-ui/translation'; + +import Filter from './Filter'; + +const $ = window.$ = require('jquery'); + +const propTypes = { + name: PropTypes.string, + onChange: PropTypes.func, + value: PropTypes.array, + datasource: PropTypes.object, +}; + +const defaultProps = { + onChange: () => {}, + value: [], +}; + +export default class FilterControl extends React.Component { + + constructor(props) { + super(props); + const initialFilters = props.value.map(() => ({ + valuesLoading: false, + valueChoices: [], + })); + this.state = { + filters: initialFilters, + activeRequest: null, + }; + } + + componentDidMount() { + this.state.filters.forEach((filter, index) => this.fetchFilterValues(index)); + } + + fetchFilterValues(index, column) { + const datasource = this.props.datasource; + const col = column || this.props.value[index].col; + const having = this.props.name === 'having_filters'; + if (col && this.props.datasource && this.props.datasource.filter_select && !having) { + this.setState((prevState) => { + const newStateFilters = Object.assign([], prevState.filters); + newStateFilters[index].valuesLoading = true; + return { filters: newStateFilters }; + }); + // if there is an outstanding request to fetch values, cancel it. + if (this.state.activeRequest) { + this.state.activeRequest.abort(); + } + this.setState({ + activeRequest: $.ajax({ + type: 'GET', + url: `/superset/filter/${datasource.type}/${datasource.id}/${col}/`, + success: (data) => { + this.setState((prevState) => { + const newStateFilters = Object.assign([], prevState.filters); + newStateFilters[index] = { valuesLoading: false, valueChoices: data }; + return { filters: newStateFilters, activeRequest: null }; + }); + }, + }), + }); + } + } + + addFilter() { + const newFilters = Object.assign([], this.props.value); + const col = this.props.datasource && this.props.datasource.filterable_cols.length > 0 ? + this.props.datasource.filterable_cols[0][0] : + null; + newFilters.push({ + col, + op: 'in', + val: this.props.datasource.filter_select ? [] : '', + }); + this.props.onChange(newFilters); + const nextIndex = this.state.filters.length; + this.setState((prevState) => { + const newStateFilters = Object.assign([], prevState.filters); + newStateFilters.push({ valuesLoading: false, valueChoices: [] }); + return { filters: newStateFilters }; + }); + this.fetchFilterValues(nextIndex, col); + } + + changeFilter(index, control, value) { + const newFilters = Object.assign([], this.props.value); + const modifiedFilter = Object.assign({}, newFilters[index]); + if (typeof control === 'string') { + modifiedFilter[control] = value; + } else { + control.forEach((c, i) => { + modifiedFilter[c] = value[i]; + }); + } + // Clear selected values and refresh upon column change + if (control === 'col') { + if (modifiedFilter.val.constructor === Array) { + modifiedFilter.val = []; + } else if (typeof modifiedFilter.val === 'string') { + modifiedFilter.val = ''; + } + this.fetchFilterValues(index, value); + } + newFilters.splice(index, 1, modifiedFilter); + this.props.onChange(newFilters); + } + + removeFilter(index) { + this.props.onChange(this.props.value.filter((f, i) => i !== index)); + this.setState((prevState) => { + const newStateFilters = Object.assign([], prevState.filters); + newStateFilters.splice(index, 1); + return { filters: newStateFilters }; + }); + } + + render() { + const filters = this.props.value.map((filter, i) => ( +
+ +
+ )); + return ( +
+ {filters} + + + + + +
+ ); + } +} + +FilterControl.propTypes = propTypes; +FilterControl.defaultProps = defaultProps; diff --git a/superset/assets/src/explore/components/controls/StepsControl.jsx b/superset/assets/src/explore/components/controls/StepsControl.jsx new file mode 100644 index 0000000000000..841d6fa6b538b --- /dev/null +++ b/superset/assets/src/explore/components/controls/StepsControl.jsx @@ -0,0 +1,181 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { ListGroup, ListGroupItem } from 'react-bootstrap'; +import { connect } from 'react-redux'; +import { maxBy } from 'lodash'; +import { t } from '@superset-ui/translation'; +import { getChartKey } from '../../exploreUtils'; +import { runAnnotationQuery } from '../../../chart/chartAction'; + +import Control from '../Control'; +import controls from '../../controls'; +import Button from '../../../components/Button'; +import savedMetricType from '../../propTypes/savedMetricType'; +import columnType from '../../propTypes/columnType'; + +const queriesLimit = 10; + +const propTypes = { + origSelectedValues: PropTypes.object, + annotationError: PropTypes.object, + annotationQuery: PropTypes.object, + vizType: PropTypes.string, + validationErrors: PropTypes.array, + name: PropTypes.string.isRequired, + actions: PropTypes.object, + value: PropTypes.object, + onChange: PropTypes.func, + refreshAnnotationData: PropTypes.func, + columns: PropTypes.arrayOf(columnType), + savedMetrics: PropTypes.arrayOf(savedMetricType), + datasourceType: PropTypes.string, + datasource: PropTypes.object, +}; + +const defaultProps = { + origSelectedValues: {}, + vizType: '', + value: {}, + annotationError: {}, + annotationQuery: {}, + onChange: () => {}, +}; + +class StepsControl extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + queries: props.value && props.value.queries || [], + selectedValues: props.value && props.value.selectedValues || props.origSelectedValues, + }; + this.addStep = this.addStep.bind(this); + this.removeStep = this.removeStep.bind(this); + this.onChange = this.onChange.bind(this); + } + + componentWillReceiveProps(nextProps) { + const { name, annotationError, validationErrors, value } = nextProps; + if (Object.keys(annotationError).length && !validationErrors.length) { + this.props.actions.setControlValue(name, value, Object.keys(annotationError)); + } + if (!Object.keys(annotationError).length && validationErrors.length) { + this.props.actions.setControlValue(name, value, []); + } + } + + onChange(id, controlName, obj) { + const selectedValues = { ...this.state.selectedValues }; + selectedValues[id] = selectedValues[id] ? + { ...selectedValues[id], [controlName]: obj } + : { [controlName]: obj }; + this.setState({ selectedValues }, () => { + this.props.onChange(this.state); + }); + } + + getControlData(controlName, id) { + const { selectedValues } = this.state; + const control = Object.assign({}, controls[controlName], { + name: controlName, + key: `control-${controlName}`, + value: selectedValues && selectedValues[id] && selectedValues[id][controlName], + actions: { setControlValue: () => {} }, + onChange: obj => this.onChange(id, controlName, obj), + }); + const mapFunc = control.mapStateToProps; + return mapFunc + ? Object.assign({}, control, mapFunc(this.props)) + : control; + } + + addStep() { + const queries = [...this.state.queries]; + const maxId = maxBy(queries, item => item.id); + const newId = maxId ? parseInt(maxId.id, 10) + 1 : 0; + queries.push({ id: newId }); + this.setState({ queries }); + } + + removeStep(id) { + const queries = this.state.queries.filter(item => item.id !== id); + const selectedValues = { ...this.state.selectedValues }; + delete selectedValues[id]; + + this.setState({ queries, selectedValues }, () => { + this.props.onChange(this.state); + }); + } + + render() { + const queries = this.state.queries.map((item, i) => ( +
+
+ Step {i + 1} + +
+ + + +
+ )); + return ( +
+ + {queries} + {(queries.length < queriesLimit) && + ( +   {t('Add Step')} + ) + } + +
+ ); + } +} + +StepsControl.propTypes = propTypes; +StepsControl.defaultProps = defaultProps; + +function mapStateToProps({ charts, explore }) { + const chartKey = getChartKey(explore); + const chart = charts[chartKey] || charts[0] || {}; + + return { + annotationError: chart.annotationError, + annotationQuery: chart.annotationQuery, + vizType: explore.controls.viz_type.value, + }; +} + +function mapDispatchToProps(dispatch) { + return { + refreshAnnotationData: annotationLayer => dispatch(runAnnotationQuery(annotationLayer)), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(StepsControl); diff --git a/superset/assets/src/explore/components/controls/index.js b/superset/assets/src/explore/components/controls/index.js index a5800f2d11c69..a505e02a83c01 100644 --- a/superset/assets/src/explore/components/controls/index.js +++ b/superset/assets/src/explore/components/controls/index.js @@ -41,6 +41,8 @@ import AdhocFilterControl from './AdhocFilterControl'; import FilterPanel from './FilterPanel'; import FilterBoxItemControl from './FilterBoxItemControl'; import withVerification from './withVerification'; +import StepsControl from './StepsControl'; +import FilterControl from './FilterControl'; const controlMap = { AnnotationLayerControl, @@ -70,5 +72,7 @@ const controlMap = { MetricsControlVerifiedOptions: withVerification(MetricsControl, 'metric_name', 'savedMetrics'), SelectControlVerifiedOptions: withVerification(SelectControl, 'column_name', 'options'), AdhocFilterControlVerifiedOptions: withVerification(AdhocFilterControl, 'column_name', 'columns'), + StepsControl, + FilterControl, }; export default controlMap; diff --git a/superset/assets/src/explore/controlPanels/Funnel.js b/superset/assets/src/explore/controlPanels/Funnel.js new file mode 100644 index 0000000000000..5fad6cb8d7c20 --- /dev/null +++ b/superset/assets/src/explore/controlPanels/Funnel.js @@ -0,0 +1,73 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { t } from '@superset-ui/translation'; + +export default { + requiresTime: true, + controlPanelSections: [ + { + label: t('Chart Options'), + expanded: true, + controlSetRows: [ + ['funnel_mode'], + ['show_delta'], + ['x_axis_label'], + ], + }, + { + label: t('Steps'), + description: t( + 'Add filters and metrics for each step.' + + 'The steps combine to tell a story.'), + expanded: true, + controlSetRows: [ + ['funnel_steps'], + ], + }, + { + label: t('Absolute Filter'), + description: t( + 'You may now apply an absolute filter ' + + 'that applies for all steps.'), + expanded: false, + controlSetRows: [ + ['abs_filter'], + ], + }, + ], + controlOverrides: { + adhoc_filters: { + renderTrigger: true, + }, + }, + + sectionOverrides: { + filters: [], + sqlaTimeSeries: { + controlSetRows: [ + ['time_range'], + ], + }, + druidTimeSeries: { + controlSetRows: [ + ['time_range'], + ], + }, + }, +}; diff --git a/superset/assets/src/explore/controlPanels/sections.jsx b/superset/assets/src/explore/controlPanels/sections.jsx index 7df0488697317..a81507b4c60c9 100644 --- a/superset/assets/src/explore/controlPanels/sections.jsx +++ b/superset/assets/src/explore/controlPanels/sections.jsx @@ -56,6 +56,12 @@ export const sqlaTimeSeries = { ], }; +export const simpleFilter = { + label: t('Filters'), + expanded: true, + controlSetRows: [['filters']], +}; + export const filters = { label: t('Filters'), expanded: true, @@ -72,6 +78,17 @@ export const annotations = { ], }; +export const steps = { + label: t('Steps'), + description: t( + 'Add filters and metrics for each step.' + + 'The steps combine to tell a story.'), + expanded: true, + controlSetRows: [ + ['funnel_steps'], + ], +}; + export const NVD3TimeSeries = [ { label: t('Query'), diff --git a/superset/assets/src/explore/controls.jsx b/superset/assets/src/explore/controls.jsx index 8f06ae6171468..4c3cdac1b6263 100644 --- a/superset/assets/src/explore/controls.jsx +++ b/superset/assets/src/explore/controls.jsx @@ -476,6 +476,22 @@ export const controls = { description: null, }, + funnel_mode: { + type: 'CheckboxControl', + label: t('Activate Funnel Mode'), + renderTrigger: true, + default: true, + description: null, + }, + + show_delta: { + type: 'CheckboxControl', + label: t('Show Delta Between Values'), + renderTrigger: true, + default: false, + description: null, + }, + pivot_margins: { type: 'CheckboxControl', label: t('Show totals'), @@ -1988,6 +2004,39 @@ export const controls = { tabOverride: 'data', }, + funnel_steps: { + type: 'StepsControl', + label: '', + default: [], + description: 'Steps', + renderTrigger: false, + tabOverride: 'data', + mapStateToProps: (state) => { + const datasource = state.datasource; + return { + columns: datasource ? datasource.columns : [], + savedMetrics: datasource ? datasource.metrics : [], + datasourceType: datasource && datasource.type, + datasource: state.datasource, + }; + }, + }, + + step_label: { + type: 'TextControl', + label: t('Label'), + renderTrigger: true, + default: '', + }, + + formatter: { + type: 'CheckboxControl', + label: t('Enable Formatter'), + renderTrigger: true, + default: true, + description: t('This enable formatter for the values for steps'), + }, + adhoc_filters: { type: 'AdhocFilterControl', label: t('Filters'), @@ -2001,10 +2050,33 @@ export const controls = { provideFormDataToProps: true, }, + abs_filter: { + type: 'FilterControl', + label: '', + default: [], + description: 'Abs Filter for Steps', + renderTrigger: false, + mapStateToProps: state => ({ + datasource: state.datasource, + }), + }, + filters: { type: 'FilterPanel', }, + having_filters: { + type: 'FilterControl', + label: '', + default: [], + description: '', + mapStateToProps: state => ({ + choices: (state.datasource) ? state.datasource.metrics_combo + .concat(state.datasource.filterable_cols) : [], + datasource: state.datasource, + }), + }, + slice_id: { type: 'HiddenControl', label: t('Chart ID'), diff --git a/superset/assets/src/setup/setupPlugins.ts b/superset/assets/src/setup/setupPlugins.ts index 57fb8dd9c127b..d0c15253801e5 100644 --- a/superset/assets/src/setup/setupPlugins.ts +++ b/superset/assets/src/setup/setupPlugins.ts @@ -45,6 +45,7 @@ import DistBar from '../explore/controlPanels/DistBar'; import DualLine from '../explore/controlPanels/DualLine'; import EventFlow from '../explore/controlPanels/EventFlow'; import FilterBox from '../explore/controlPanels/FilterBox'; +import Funnel from '../explore/controlPanels/Funnel'; import Heatmap from '../explore/controlPanels/Heatmap'; import Histogram from '../explore/controlPanels/Histogram'; import Horizon from '../explore/controlPanels/Horizon'; @@ -90,6 +91,7 @@ export default function setupPlugins() { .registerValue('dual_line', DualLine) .registerValue('event_flow', EventFlow) .registerValue('filter_box', FilterBox) + .registerValue('funnel', Funnel) .registerValue('heatmap', Heatmap) .registerValue('histogram', Histogram) .registerValue('horizon', Horizon) diff --git a/superset/assets/src/visualizations/Funnel/Funnel.css b/superset/assets/src/visualizations/Funnel/Funnel.css new file mode 100644 index 0000000000000..f27a0659d9422 --- /dev/null +++ b/superset/assets/src/visualizations/Funnel/Funnel.css @@ -0,0 +1,21 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +.m-b-5 { + margin-bottom: 5px; +} diff --git a/superset/assets/src/visualizations/Funnel/Funnel.jsx b/superset/assets/src/visualizations/Funnel/Funnel.jsx new file mode 100644 index 0000000000000..a29be0fcf1248 --- /dev/null +++ b/superset/assets/src/visualizations/Funnel/Funnel.jsx @@ -0,0 +1,135 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/* eslint camelcase: 0 */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { map } from 'lodash'; + +import './Funnel.css'; +import ChartRenderer from '../../chart/ChartRenderer'; + +const propTypes = { + rawFormData: PropTypes.object, + width: PropTypes.number, + height: PropTypes.number, + queryData: PropTypes.array.isRequired, + funnelSteps: PropTypes.array.isRequired, + datasource: PropTypes.object.isRequired, + actions: PropTypes.object.isRequired, +}; + +const defaultFormData = { + barstacked: false, + bottomMargin: 'auto', + colorScheme: 'd3Category10', + contribution: false, + orderBars: false, + reduceXTicks: false, + showBarValue: false, + showControls: false, + showLegend: true, + vizType: 'dist_bar', + xTicksLayout: 'auto', + yAxisFormat: ',d', +}; + +class Funnel extends React.Component { + constructor(props) { + super(props); + this.formatQueryResponse = this.formatQueryResponse.bind(this); + this.formatValues = this.formatValues.bind(this); + } + + formatQueryResponse(funnelSteps) { + const selectedValues = funnelSteps && funnelSteps.selectedValues; + const selValues = map(selectedValues, item => item); + const values = []; + let prevValue = 0; + Object.values(this.props.queryData.data).forEach((value, index) => { + const label = selValues && selValues[index] && selValues[index].step_label; + + // Return Delta between each step Visualization + const roundedValue = value > 0 ? 100 : 0; + const delta = prevValue > 0 ? + Math.round(100 * value / prevValue, 1) - 100 + : roundedValue; + const deltaStr = `${delta}%`; + const tag_label = label || `Step ${index + 1}`; + const valueObj = this.formatValues({ tag_label, index, value, deltaStr }); + values.push(valueObj); + prevValue = value; + }); + return values; + } + + formatValues(formatObj, formatOptions = this.props.rawFormData) { + const { tag_label, index, value, deltaStr } = formatObj; + const { funnel_mode, x_axis_label, show_delta } = formatOptions; + + const chart_label = funnel_mode ? + x_axis_label || 'Funnel/Step Visualizaiton' + : tag_label; + + const tagLabel = index > 0 && show_delta ? + `${tag_label}, ${deltaStr}` + : tag_label; + + const valueOutput = { key: tagLabel, values: [{ y: value, x: chart_label }] }; + + if (funnel_mode) { + valueOutput.values.push({ y: -value, x: chart_label }); + } + return valueOutput; + } + render() { + const { funnelSteps, + width, + height, + datasource, + actions, + queryData } = this.props; + + const formatedData = this.formatQueryResponse(funnelSteps); + + return ( +
+
+ +
+
+ ); + } +} + +Funnel.propTypes = propTypes; + +export default Funnel; diff --git a/superset/assets/src/visualizations/Funnel/FunnelChartPlugin.js b/superset/assets/src/visualizations/Funnel/FunnelChartPlugin.js new file mode 100644 index 0000000000000..67a5bf618ffee --- /dev/null +++ b/superset/assets/src/visualizations/Funnel/FunnelChartPlugin.js @@ -0,0 +1,38 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { t } from '@superset-ui/translation'; +import { ChartMetadata, ChartPlugin } from '@superset-ui/chart'; +import transformProps from './transformProps'; +import thumbnail from './images/thumbnail.png'; + +const metadata = new ChartMetadata({ + name: t('Funnel'), + description: 'Funnel', + thumbnail, +}); + +export default class FunnelChartPlugin extends ChartPlugin { + constructor() { + super({ + metadata, + transformProps, + loadChart: () => import('./FunnelContainer.js'), + }); + } +} diff --git a/superset/assets/src/visualizations/Funnel/FunnelContainer.js b/superset/assets/src/visualizations/Funnel/FunnelContainer.js new file mode 100644 index 0000000000000..41f68b83e19aa --- /dev/null +++ b/superset/assets/src/visualizations/Funnel/FunnelContainer.js @@ -0,0 +1,34 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import * as actions from 'src/chart/chartAction'; +import { logEvent } from 'src/logger/actions'; +import Funnel from './Funnel'; + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + ...actions, + logEvent, + }, dispatch), + }; +} + +export default connect(null, mapDispatchToProps)(Funnel); diff --git a/superset/assets/src/visualizations/Funnel/images/thumbnail.png b/superset/assets/src/visualizations/Funnel/images/thumbnail.png new file mode 100644 index 0000000000000..399344e398633 Binary files /dev/null and b/superset/assets/src/visualizations/Funnel/images/thumbnail.png differ diff --git a/superset/assets/src/visualizations/Funnel/images/thumbnailLarge.png b/superset/assets/src/visualizations/Funnel/images/thumbnailLarge.png new file mode 100644 index 0000000000000..399344e398633 Binary files /dev/null and b/superset/assets/src/visualizations/Funnel/images/thumbnailLarge.png differ diff --git a/superset/assets/src/visualizations/Funnel/transformProps.js b/superset/assets/src/visualizations/Funnel/transformProps.js new file mode 100644 index 0000000000000..e78053d5e685b --- /dev/null +++ b/superset/assets/src/visualizations/Funnel/transformProps.js @@ -0,0 +1,43 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export default function transformProps(chartProps) { + const { + datasource, + filters, + formData, + rawDatasource, + rawFormData, + queryData, + width, + height, + hooks, + } = chartProps; + + return { + verboseMap: datasource.verboseMap, + datasource: rawDatasource, + funnelSteps: formData.funnelSteps, + origSelectedValues: filters || {}, + rawFormData, + queryData, + height, + width, + hooks, + }; +} diff --git a/superset/assets/src/visualizations/presets/MainPreset.js b/superset/assets/src/visualizations/presets/MainPreset.js index 3ea5592339c16..54969a6075302 100644 --- a/superset/assets/src/visualizations/presets/MainPreset.js +++ b/superset/assets/src/visualizations/presets/MainPreset.js @@ -53,6 +53,7 @@ import { DeckGLChartPreset } from '@superset-ui/legacy-preset-chart-deckgl'; import FilterBoxChartPlugin from '../FilterBox/FilterBoxChartPlugin'; import TimeTableChartPlugin from '../TimeTable/TimeTableChartPlugin'; +import FunnelChartPlugin from '../Funnel/FunnelChartPlugin'; export default class MainPreset extends Preset { constructor() { @@ -75,6 +76,7 @@ export default class MainPreset extends Preset { new CountryMapChartPlugin().configure({ key: 'country_map' }), new DistBarChartPlugin().configure({ key: 'dist_bar' }), new DualLineChartPlugin().configure({ key: 'dual_line' }), + new FunnelChartPlugin().configure({ key: 'funnel' }), new EventFlowChartPlugin().configure({ key: 'event_flow' }), new FilterBoxChartPlugin().configure({ key: 'filter_box' }), new ForceDirectedChartPlugin().configure({ key: 'directed_force' }), diff --git a/superset/viz.py b/superset/viz.py index edd5b1caa4344..c6be1fda7129a 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -357,6 +357,7 @@ def cache_key(self, query_obj, **extra): different time shifts wil differ only in the `from_dttm` and `to_dttm` values which are stripped. """ + cache_dict = copy.copy(query_obj) cache_dict.update(extra) @@ -603,6 +604,146 @@ def json_dumps(self, obj, sort_keys=False): ) +class FunnelViz(BaseViz): + """A Bar graph or 'STEP' Visualization that makes story telling easy through multipass queries""" + + viz_type = "funnel" + verbose_name = _("Funnel") + is_timeseries = False + credits = 'a Superset original' + cache_type = "get_data" + filter_row_limit = 1000 + is_multipass = True + + def _query_obj(self): + return super().query_obj() + + def get_samples(self): + query_obj_array = self.query_obj() + step_count = len(self.form_data["funnel_steps"]["queries"]) + for index in range(0, step_count): + query_obj = query_obj_array[str(index)] + query_obj.update( + { + "groupby": [], + "metrics": [], + "row_limit": 1000, + "columns": [o.column_name for o in self.datasource.columns], + } + ) + + df = self.get_df(query_obj) + + return df.to_dict(orient="records") + + def query_obj(self): + if not self.form_data.get("funnel_steps"): + raise Exception(_("Add at least one Step")) + + sel_values = self.form_data["funnel_steps"]["selectedValues"] + step_count = len(sel_values) + + filter_store = [] + self.form_data["granularity"] = "all" + if self.form_data.get("filters"): + filter_store = self.form_data["filters"] + + result_query = {} + for i in range(step_count): + index = str(i) + mod_form_data = self.form_data.copy() + mod_form_data["metrics"] = [] + mod_form_data["adhoc_filters"] = [] + mod_form_data["filters"] = [] + mod_form_data["metrics"].append(sel_values[index]["metric"]) + adhoc_filters = sel_values[index].get("adhoc_filters") + if adhoc_filters: + mod_form_data["adhoc_filters"] = adhoc_filters + + qry = self.__class__( + form_data=mod_form_data, datasource=self.datasource + )._query_obj() + + # Apply 'Absolute Filter' to Query + if filter_store: + for ifilter in filter_store: + qry["filter"].append(ifilter) + + result_query[str(i)] = qry + return result_query + + def cache_key(self, query_obj, **extra): + if "0" in query_obj: + query_obj = query_obj["0"] + + cache_dict = copy.copy(query_obj) + cache_dict.update(extra) + + for k in ["from_dttm", "to_dttm"]: + del cache_dict[k] + + cache_dict["time_range"] = self.form_data.get("time_range") + cache_dict["datasource"] = self.datasource.uid + cache_dict["extra_cache_keys"] = self.datasource.get_extra_cache_keys(query_obj) + json_data = self.json_dumps(cache_dict, sort_keys=True) + return hashlib.md5(json_data.encode("utf-8")).hexdigest() + + def get_df_payload(self, query_obj=None, **kwargs): + """Special handeling of Cache due to multipass nature of query """ + if not query_obj: + query_obj = self.query_obj() + cache_key = self.cache_key(query_obj, **kwargs) if query_obj else None + logging.info("Cache key: {}".format(cache_key)) + stacktrace = None + df = None + if cache_key and cache and not self.force: + cache_value = cache.get(cache_key) + if cache_value: + stats_logger.incr("loaded_from_cache") + try: + cache_value = pkl.loads(cache_value) + df = cache_value["df"] + self.query = cache_value["query"] + self._any_cached_dttm = cache_value["dttm"] + self._any_cache_key = cache_key + self.status = utils.QueryStatus.SUCCESS + except Exception as e: + logging.exception(e) + logging.error( + "Error reading cache: " + utils.error_msg_from_exception(e) + ) + logging.info("Serving from cache") + + if query_obj: + return { + "cache_key": self._any_cache_key, + "cached_dttm": self._any_cached_dttm, + "cache_timeout": self.cache_timeout, + "df": df, + "error": self.error_message, + "form_data": self.form_data, + "is_cached": self._any_cache_key is not None, + "query": self.query, + "status": self.status, + "stacktrace": stacktrace, + "rowcount": len(df.index) if df is not None else 0, + } + + def get_data(self, df): + agg_queries = {} + all_queries = self.query_obj() + for query in all_queries: + df = self.get_df(query_obj=all_queries[query]) + obj = df.to_dict(orient="records")[0] + key = list(obj.keys())[0] + if key not in agg_queries: + agg_queries.update(obj) + else: + new_key = key + "_" + query + agg_queries.update({new_key: obj[key]}) + return agg_queries + + class TimeTableViz(BaseViz): """A data table with rich time-series related columns"""