diff --git a/superset/assets/spec/javascripts/explore/AdhocFilter_spec.js b/superset/assets/spec/javascripts/explore/AdhocFilter_spec.js
new file mode 100644
index 0000000000000..0cf9e58e1f310
--- /dev/null
+++ b/superset/assets/spec/javascripts/explore/AdhocFilter_spec.js
@@ -0,0 +1,136 @@
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+
+import AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../../../src/explore/AdhocFilter';
+
+describe('AdhocFilter', () => {
+ it('sets filterOptionName in constructor', () => {
+ const adhocFilter = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: '>',
+ comparator: '10',
+ clause: CLAUSES.WHERE,
+ });
+ expect(adhocFilter.filterOptionName.length).to.be.above(10);
+ expect(adhocFilter).to.deep.equal({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: '>',
+ comparator: '10',
+ clause: CLAUSES.WHERE,
+ filterOptionName: adhocFilter.filterOptionName,
+ sqlExpression: null,
+ fromFormData: false,
+ });
+ });
+
+ it('can create altered duplicates', () => {
+ const adhocFilter1 = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: '>',
+ comparator: '10',
+ clause: CLAUSES.WHERE,
+ });
+ const adhocFilter2 = adhocFilter1.duplicateWith({ operator: '<' });
+
+ expect(adhocFilter1.subject).to.equal(adhocFilter2.subject);
+ expect(adhocFilter1.comparator).to.equal(adhocFilter2.comparator);
+ expect(adhocFilter1.clause).to.equal(adhocFilter2.clause);
+ expect(adhocFilter1.expressionType).to.equal(adhocFilter2.expressionType);
+
+ expect(adhocFilter1.operator).to.equal('>');
+ expect(adhocFilter2.operator).to.equal('<');
+ });
+
+ it('can verify equality', () => {
+ const adhocFilter1 = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: '>',
+ comparator: '10',
+ clause: CLAUSES.WHERE,
+ });
+ const adhocFilter2 = adhocFilter1.duplicateWith({});
+
+ // eslint-disable-next-line no-unused-expressions
+ expect(adhocFilter1.equals(adhocFilter2)).to.be.true;
+ // eslint-disable-next-line no-unused-expressions
+ expect(adhocFilter1 === adhocFilter2).to.be.false;
+ });
+
+ it('can verify inequality', () => {
+ const adhocFilter1 = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: '>',
+ comparator: '10',
+ clause: CLAUSES.WHERE,
+ });
+ const adhocFilter2 = adhocFilter1.duplicateWith({ operator: '<' });
+
+ // eslint-disable-next-line no-unused-expressions
+ expect(adhocFilter1.equals(adhocFilter2)).to.be.false;
+
+ const adhocFilter3 = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SQL,
+ sqlExpression: 'value > 10',
+ clause: CLAUSES.WHERE,
+ });
+ const adhocFilter4 = adhocFilter3.duplicateWith({ sqlExpression: 'value = 5' });
+
+ // eslint-disable-next-line no-unused-expressions
+ expect(adhocFilter3.equals(adhocFilter4)).to.be.false;
+ });
+
+ it('can determine if it is valid', () => {
+ const adhocFilter1 = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: '>',
+ comparator: '10',
+ clause: CLAUSES.WHERE,
+ });
+ // eslint-disable-next-line no-unused-expressions
+ expect(adhocFilter1.isValid()).to.be.true;
+
+ const adhocFilter2 = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: '>',
+ comparator: null,
+ clause: CLAUSES.WHERE,
+ });
+ // eslint-disable-next-line no-unused-expressions
+ expect(adhocFilter2.isValid()).to.be.false;
+
+ const adhocFilter3 = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SQL,
+ sqlExpression: 'some expression',
+ clause: null,
+ });
+ // eslint-disable-next-line no-unused-expressions
+ expect(adhocFilter3.isValid()).to.be.false;
+ });
+
+ it('can translate from simple expressions to sql expressions', () => {
+ const adhocFilter1 = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: '==',
+ comparator: '10',
+ clause: CLAUSES.WHERE,
+ });
+ expect(adhocFilter1.translateToSql()).to.equal('value = 10');
+
+ const adhocFilter2 = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'SUM(value)',
+ operator: '!=',
+ comparator: '5',
+ clause: CLAUSES.HAVING,
+ });
+ expect(adhocFilter2.translateToSql()).to.equal('SUM(value) <> 5');
+ });
+});
diff --git a/superset/assets/spec/javascripts/explore/components/AdhocFilterControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/AdhocFilterControl_spec.jsx
new file mode 100644
index 0000000000000..4be8a2eba3c4c
--- /dev/null
+++ b/superset/assets/spec/javascripts/explore/components/AdhocFilterControl_spec.jsx
@@ -0,0 +1,189 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import sinon from 'sinon';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+
+import AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../../../../src/explore/AdhocFilter';
+import AdhocFilterControl from '../../../../src/explore/components/controls/AdhocFilterControl';
+import AdhocMetric from '../../../../src/explore/AdhocMetric';
+import { AGGREGATES, OPERATORS } from '../../../../src/explore/constants';
+import OnPasteSelect from '../../../../src/components/OnPasteSelect';
+
+const simpleAdhocFilter = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: '>',
+ comparator: '10',
+ clause: CLAUSES.WHERE,
+});
+
+const sumValueAdhocMetric = new AdhocMetric({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ column: { type: 'VARCHAR(255)', column_name: 'source' },
+ aggregate: AGGREGATES.SUM,
+});
+
+const savedMetric = { metric_name: 'sum__value', expression: 'SUM(value)' };
+
+const columns = [
+ { type: 'VARCHAR(255)', column_name: 'source' },
+ { type: 'VARCHAR(255)', column_name: 'target' },
+ { type: 'DOUBLE', column_name: 'value' },
+];
+
+const legacyFilter = { col: 'value', op: '>', val: '5' };
+const legacyHavingFilter = { col: 'SUM(value)', op: '>', val: '10' };
+const whereFilterText = 'target in (\'alpha\')';
+const havingFilterText = 'SUM(value) < 20';
+
+const formData = {
+ filters: [legacyFilter],
+ having: havingFilterText,
+ having_filters: [legacyHavingFilter],
+ metric: undefined,
+ metrics: [sumValueAdhocMetric, savedMetric.saved_metric_name],
+ where: whereFilterText,
+};
+
+function setup(overrides) {
+ const onChange = sinon.spy();
+ const props = {
+ onChange,
+ value: [simpleAdhocFilter],
+ datasource: { type: 'table' },
+ columns,
+ savedMetrics: [savedMetric],
+ formData,
+ ...overrides,
+ };
+ const wrapper = shallow();
+ return { wrapper, onChange };
+}
+
+describe('AdhocFilterControl', () => {
+ it('renders an onPasteSelect', () => {
+ const { wrapper } = setup();
+ expect(wrapper.find(OnPasteSelect)).to.have.lengthOf(1);
+ });
+
+ it('will translate legacy filters into adhoc filters if no adhoc filters are present', () => {
+ const { wrapper } = setup({ value: undefined });
+ expect(wrapper.state('values')).to.have.lengthOf(4);
+ expect(wrapper.state('values')[0].equals((
+ new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: '>',
+ comparator: '5',
+ clause: CLAUSES.WHERE,
+ })
+ ))).to.be.true;
+ expect(wrapper.state('values')[1].equals((
+ new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'SUM(value)',
+ operator: '>',
+ comparator: '10',
+ clause: CLAUSES.HAVING,
+ })
+ ))).to.be.true;
+ expect(wrapper.state('values')[2].equals((
+ new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SQL,
+ sqlExpression: 'target in (\'alpha\')',
+ clause: CLAUSES.WHERE,
+ })
+ ))).to.be.true;
+ expect(wrapper.state('values')[3].equals((
+ new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SQL,
+ sqlExpression: 'SUM(value) < 20',
+ clause: CLAUSES.HAVING,
+ })
+ ))).to.be.true;
+ });
+
+ it('will ignore legacy filters if adhoc filters are present', () => {
+ const { wrapper } = setup();
+ expect(wrapper.state('values')).to.have.lengthOf(1);
+ expect(wrapper.state('values')[0]).to.equal(simpleAdhocFilter);
+ });
+
+ it('handles saved metrics being selected to filter on', () => {
+ const { wrapper, onChange } = setup({ value: [] });
+ const select = wrapper.find(OnPasteSelect);
+ select.simulate('change', [{ saved_metric_name: 'sum__value' }]);
+
+ const adhocFilter = onChange.lastCall.args[0][0];
+ expect(adhocFilter instanceof AdhocFilter).to.be.true;
+ expect(adhocFilter.equals((
+ new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SQL,
+ subject: savedMetric.expression,
+ operator: OPERATORS['>'],
+ comparator: 0,
+ clause: CLAUSES.HAVING,
+ })
+ ))).to.be.true;
+ });
+
+ it('handles adhoc metrics being selected to filter on', () => {
+ const { wrapper, onChange } = setup({ value: [] });
+ const select = wrapper.find(OnPasteSelect);
+ select.simulate('change', [sumValueAdhocMetric]);
+
+ const adhocFilter = onChange.lastCall.args[0][0];
+ expect(adhocFilter instanceof AdhocFilter).to.be.true;
+ expect(adhocFilter.equals((
+ new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SQL,
+ subject: sumValueAdhocMetric.label,
+ operator: OPERATORS['>'],
+ comparator: 0,
+ clause: CLAUSES.HAVING,
+ })
+ ))).to.be.true;
+ });
+
+ it('handles columns being selected to filter on', () => {
+ const { wrapper, onChange } = setup({ value: [] });
+ const select = wrapper.find(OnPasteSelect);
+ select.simulate('change', [columns[0]]);
+
+ const adhocFilter = onChange.lastCall.args[0][0];
+ expect(adhocFilter instanceof AdhocFilter).to.be.true;
+ expect(adhocFilter.equals((
+ new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: columns[0].column_name,
+ operator: OPERATORS['=='],
+ comparator: '',
+ clause: CLAUSES.WHERE,
+ })
+ ))).to.be.true;
+ });
+
+ it('persists existing filters even when new filters are added', () => {
+ const { wrapper, onChange } = setup();
+ const select = wrapper.find(OnPasteSelect);
+ select.simulate('change', [simpleAdhocFilter, columns[0]]);
+
+ const existingAdhocFilter = onChange.lastCall.args[0][0];
+ expect(existingAdhocFilter instanceof AdhocFilter).to.be.true;
+ expect(existingAdhocFilter.equals(simpleAdhocFilter)).to.be.true;
+
+ const newAdhocFilter = onChange.lastCall.args[0][1];
+ expect(newAdhocFilter instanceof AdhocFilter).to.be.true;
+ expect(newAdhocFilter.equals((
+ new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: columns[0].column_name,
+ operator: OPERATORS['=='],
+ comparator: '',
+ clause: CLAUSES.WHERE,
+ })
+ ))).to.be.true;
+ });
+});
diff --git a/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSimpleTabContent_spec.jsx b/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSimpleTabContent_spec.jsx
new file mode 100644
index 0000000000000..005b287626b04
--- /dev/null
+++ b/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSimpleTabContent_spec.jsx
@@ -0,0 +1,122 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import sinon from 'sinon';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+import { FormGroup } from 'react-bootstrap';
+
+import AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../../../../src/explore/AdhocFilter';
+import AdhocMetric from '../../../../src/explore/AdhocMetric';
+import AdhocFilterEditPopoverSimpleTabContent from '../../../../src/explore/components/AdhocFilterEditPopoverSimpleTabContent';
+import { AGGREGATES } from '../../../../src/explore/constants';
+
+const simpleAdhocFilter = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: '>',
+ comparator: '10',
+ clause: CLAUSES.WHERE,
+});
+
+const simpleMultiAdhocFilter = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: 'in',
+ comparator: ['10'],
+ clause: CLAUSES.WHERE,
+});
+
+const sumValueAdhocMetric = new AdhocMetric({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ column: { type: 'VARCHAR(255)', column_name: 'source' },
+ aggregate: AGGREGATES.SUM,
+});
+
+const options = [
+ { type: 'VARCHAR(255)', column_name: 'source' },
+ { type: 'VARCHAR(255)', column_name: 'target' },
+ { type: 'DOUBLE', column_name: 'value' },
+ { saved_metric_name: 'my_custom_metric' },
+ sumValueAdhocMetric,
+];
+
+function setup(overrides) {
+ const onChange = sinon.spy();
+ const props = {
+ adhocFilter: simpleAdhocFilter,
+ onChange,
+ options,
+ datasource: {},
+ ...overrides,
+ };
+ const wrapper = shallow();
+ return { wrapper, onChange };
+}
+
+describe('AdhocFilterEditPopoverSimpleTabContent', () => {
+ it('renders the simple tab form', () => {
+ const { wrapper } = setup();
+ expect(wrapper.find(FormGroup)).to.have.lengthOf(3);
+ });
+
+ it('passes the new adhocFilter to onChange after onSubjectChange', () => {
+ const { wrapper, onChange } = setup();
+ wrapper.instance().onSubjectChange({ type: 'VARCHAR(255)', column_name: 'source' });
+ expect(onChange.calledOnce).to.be.true;
+ expect(onChange.lastCall.args[0].equals((
+ simpleAdhocFilter.duplicateWith({ subject: 'source' })
+ ))).to.be.true;
+ });
+
+ it('may alter the clause in onSubjectChange if the old clause is not appropriate', () => {
+ const { wrapper, onChange } = setup();
+ wrapper.instance().onSubjectChange(sumValueAdhocMetric);
+ expect(onChange.calledOnce).to.be.true;
+ expect(onChange.lastCall.args[0].equals((
+ simpleAdhocFilter.duplicateWith({
+ subject: sumValueAdhocMetric.label,
+ clause: CLAUSES.HAVING,
+ })
+ ))).to.be.true;
+ });
+
+ it('will convert from individual comparator to array if the operator changes to multi', () => {
+ const { wrapper, onChange } = setup();
+ wrapper.instance().onOperatorChange({ operator: 'in' });
+ expect(onChange.calledOnce).to.be.true;
+ expect(onChange.lastCall.args[0].comparator).to.have.lengthOf(1);
+ expect(onChange.lastCall.args[0].comparator[0]).to.equal('10');
+ expect(onChange.lastCall.args[0].operator).to.equal('in');
+ });
+
+ it('will convert from array to individual comparators if the operator changes from multi', () => {
+ const { wrapper, onChange } = setup({ adhocFilter: simpleMultiAdhocFilter });
+ wrapper.instance().onOperatorChange({ operator: '<' });
+ expect(onChange.calledOnce).to.be.true;
+ expect(onChange.lastCall.args[0].equals((
+ simpleAdhocFilter.duplicateWith({ operator: '<', comparator: '10' })
+ ))).to.be.true;
+ });
+
+ it('passes the new adhocFilter to onChange after onComparatorChange', () => {
+ const { wrapper, onChange } = setup();
+ wrapper.instance().onComparatorChange('20');
+ expect(onChange.calledOnce).to.be.true;
+ expect(onChange.lastCall.args[0].equals((
+ simpleAdhocFilter.duplicateWith({ comparator: '20' })
+ ))).to.be.true;
+ });
+
+ it('will filter operators for table datasources', () => {
+ const { wrapper } = setup({ datasource: { type: 'table' } });
+ expect(wrapper.instance().isOperatorRelevant('regex')).to.be.false;
+ expect(wrapper.instance().isOperatorRelevant('like')).to.be.true;
+ });
+
+ it('will filter operators for druid datasources', () => {
+ const { wrapper } = setup({ datasource: { type: 'druid' } });
+ expect(wrapper.instance().isOperatorRelevant('regex')).to.be.true;
+ expect(wrapper.instance().isOperatorRelevant('like')).to.be.false;
+ });
+});
diff --git a/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSqlTabContent_spec.jsx b/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSqlTabContent_spec.jsx
new file mode 100644
index 0000000000000..a1cdb232472ad
--- /dev/null
+++ b/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSqlTabContent_spec.jsx
@@ -0,0 +1,54 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import sinon from 'sinon';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+import { FormGroup } from 'react-bootstrap';
+
+import AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../../../../src/explore/AdhocFilter';
+import AdhocFilterEditPopoverSqlTabContent from '../../../../src/explore/components/AdhocFilterEditPopoverSqlTabContent';
+
+const sqlAdhocFilter = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SQL,
+ sqlExpression: 'value > 10',
+ clause: CLAUSES.WHERE,
+});
+
+function setup(overrides) {
+ const onChange = sinon.spy();
+ const props = {
+ adhocFilter: sqlAdhocFilter,
+ onChange,
+ options: [],
+ height: 100,
+ ...overrides,
+ };
+ const wrapper = shallow();
+ return { wrapper, onChange };
+}
+
+describe('AdhocFilterEditPopoverSqlTabContent', () => {
+ it('renders the sql tab form', () => {
+ const { wrapper } = setup();
+ expect(wrapper.find(FormGroup)).to.have.lengthOf(2);
+ });
+
+ it('passes the new clause to onChange after onSqlExpressionClauseChange', () => {
+ const { wrapper, onChange } = setup();
+ wrapper.instance().onSqlExpressionClauseChange(CLAUSES.HAVING);
+ expect(onChange.calledOnce).to.be.true;
+ expect(onChange.lastCall.args[0].equals((
+ sqlAdhocFilter.duplicateWith({ clause: CLAUSES.HAVING })
+ ))).to.be.true;
+ });
+
+ it('passes the new query to onChange after onSqlExpressionChange', () => {
+ const { wrapper, onChange } = setup();
+ wrapper.instance().onSqlExpressionChange('value < 5');
+ expect(onChange.calledOnce).to.be.true;
+ expect(onChange.lastCall.args[0].equals((
+ sqlAdhocFilter.duplicateWith({ sqlExpression: 'value < 5' })
+ ))).to.be.true;
+ });
+});
diff --git a/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopover_spec.jsx b/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopover_spec.jsx
new file mode 100644
index 0000000000000..3b062ed272855
--- /dev/null
+++ b/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopover_spec.jsx
@@ -0,0 +1,112 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import sinon from 'sinon';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+import { Button, Popover, Tab, Tabs } from 'react-bootstrap';
+
+import AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../../../../src/explore/AdhocFilter';
+import AdhocMetric from '../../../../src/explore/AdhocMetric';
+import AdhocFilterEditPopover from '../../../../src/explore/components/AdhocFilterEditPopover';
+import AdhocFilterEditPopoverSimpleTabContent from '../../../../src/explore/components/AdhocFilterEditPopoverSimpleTabContent';
+import AdhocFilterEditPopoverSqlTabContent from '../../../../src/explore/components/AdhocFilterEditPopoverSqlTabContent';
+import { AGGREGATES } from '../../../../src/explore/constants';
+
+const simpleAdhocFilter = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: '>',
+ comparator: '10',
+ clause: CLAUSES.WHERE,
+});
+
+const sqlAdhocFilter = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SQL,
+ sqlExpression: 'value > 10',
+ clause: CLAUSES.WHERE,
+});
+
+const sumValueAdhocMetric = new AdhocMetric({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ column: { type: 'VARCHAR(255)', column_name: 'source' },
+ aggregate: AGGREGATES.SUM,
+});
+
+const options = [
+ { type: 'VARCHAR(255)', column_name: 'source' },
+ { type: 'VARCHAR(255)', column_name: 'target' },
+ { type: 'DOUBLE', column_name: 'value' },
+ { saved_metric_name: 'my_custom_metric' },
+ sumValueAdhocMetric,
+];
+
+function setup(overrides) {
+ const onChange = sinon.spy();
+ const onClose = sinon.spy();
+ const onResize = sinon.spy();
+ const props = {
+ adhocFilter: simpleAdhocFilter,
+ onChange,
+ onClose,
+ onResize,
+ options,
+ datasource: {},
+ ...overrides,
+ };
+ const wrapper = shallow();
+ return { wrapper, onChange, onClose, onResize };
+}
+
+describe('AdhocFilterEditPopover', () => {
+ it('renders simple tab content by default', () => {
+ const { wrapper } = setup();
+ expect(wrapper.find(Popover)).to.have.lengthOf(1);
+ expect(wrapper.find(Tabs)).to.have.lengthOf(1);
+ expect(wrapper.find(Tab)).to.have.lengthOf(2);
+ expect(wrapper.find(Button)).to.have.lengthOf(2);
+ expect(wrapper.find(AdhocFilterEditPopoverSimpleTabContent)).to.have.lengthOf(1);
+ });
+
+ it('renders sql tab content when the adhoc filter expressionType is sql', () => {
+ const { wrapper } = setup({ adhocFilter: sqlAdhocFilter });
+ expect(wrapper.find(Popover)).to.have.lengthOf(1);
+ expect(wrapper.find(Tabs)).to.have.lengthOf(1);
+ expect(wrapper.find(Tab)).to.have.lengthOf(2);
+ expect(wrapper.find(Button)).to.have.lengthOf(2);
+ expect(wrapper.find(AdhocFilterEditPopoverSqlTabContent)).to.have.lengthOf(1);
+ });
+
+ it('overwrites the adhocFilter in state with onAdhocFilterChange', () => {
+ const { wrapper } = setup();
+ wrapper.instance().onAdhocFilterChange(sqlAdhocFilter);
+ expect(wrapper.state('adhocFilter')).to.deep.equal(sqlAdhocFilter);
+ });
+
+ it('prevents saving if the filter is invalid', () => {
+ const { wrapper } = setup();
+ expect(wrapper.find(Button).find({ disabled: true })).to.have.lengthOf(0);
+ wrapper.instance().onAdhocFilterChange(simpleAdhocFilter.duplicateWith({ operator: null }));
+ expect(wrapper.find(Button).find({ disabled: true })).to.have.lengthOf(1);
+ wrapper.instance().onAdhocFilterChange(sqlAdhocFilter);
+ expect(wrapper.find(Button).find({ disabled: true })).to.have.lengthOf(0);
+ });
+
+ it('highlights save if changes are present', () => {
+ const { wrapper } = setup();
+ expect(wrapper.find(Button).find({ bsStyle: 'primary' })).to.have.lengthOf(0);
+ wrapper.instance().onAdhocFilterChange(sqlAdhocFilter);
+ expect(wrapper.find(Button).find({ bsStyle: 'primary' })).to.have.lengthOf(1);
+ });
+
+ it('will initiate a drag when clicked', () => {
+ const { wrapper } = setup();
+ wrapper.instance().onDragDown = sinon.spy();
+ wrapper.instance().forceUpdate();
+
+ expect(wrapper.find('i.glyphicon-resize-full')).to.have.lengthOf(1);
+ expect(wrapper.instance().onDragDown.calledOnce).to.be.false;
+ wrapper.find('i.glyphicon-resize-full').simulate('mouseDown');
+ expect(wrapper.instance().onDragDown.calledOnce).to.be.true;
+ });
+});
diff --git a/superset/assets/spec/javascripts/explore/components/AdhocFilterOption_spec.jsx b/superset/assets/spec/javascripts/explore/components/AdhocFilterOption_spec.jsx
new file mode 100644
index 0000000000000..673b854e5c8f4
--- /dev/null
+++ b/superset/assets/spec/javascripts/explore/components/AdhocFilterOption_spec.jsx
@@ -0,0 +1,39 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import sinon from 'sinon';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+import { Label, OverlayTrigger } from 'react-bootstrap';
+
+import AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../../../../src/explore/AdhocFilter';
+import AdhocFilterOption from '../../../../src/explore/components/AdhocFilterOption';
+
+const simpleAdhocFilter = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: '>',
+ comparator: '10',
+ clause: CLAUSES.WHERE,
+});
+
+function setup(overrides) {
+ const onFilterEdit = sinon.spy();
+ const props = {
+ adhocFilter: simpleAdhocFilter,
+ onFilterEdit,
+ options: [],
+ datasource: {},
+ ...overrides,
+ };
+ const wrapper = shallow();
+ return { wrapper };
+}
+
+describe('AdhocFilterOption', () => {
+ it('renders an overlay trigger wrapper for the label', () => {
+ const { wrapper } = setup();
+ expect(wrapper.find(OverlayTrigger)).to.have.lengthOf(1);
+ expect(wrapper.find(Label)).to.have.lengthOf(1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/explore/components/AdhocMetricStaticOption_spec.jsx b/superset/assets/spec/javascripts/explore/components/AdhocMetricStaticOption_spec.jsx
new file mode 100644
index 0000000000000..54ff78e66f135
--- /dev/null
+++ b/superset/assets/spec/javascripts/explore/components/AdhocMetricStaticOption_spec.jsx
@@ -0,0 +1,22 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+
+import AdhocMetricStaticOption from '../../../../src/explore/components/AdhocMetricStaticOption';
+import AdhocMetric, { EXPRESSION_TYPES } from '../../../../src/explore/AdhocMetric';
+import { AGGREGATES } from '../../../../src/explore/constants';
+
+const sumValueAdhocMetric = new AdhocMetric({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ column: { type: 'VARCHAR(255)', column_name: 'source' },
+ aggregate: AGGREGATES.SUM,
+});
+
+describe('AdhocMetricStaticOption', () => {
+ it('renders the adhoc metrics label', () => {
+ const wrapper = shallow();
+ expect(wrapper.text()).to.equal('SUM(source)');
+ });
+});
diff --git a/superset/assets/spec/javascripts/explore/components/FilterDefinitionOption_spec.jsx b/superset/assets/spec/javascripts/explore/components/FilterDefinitionOption_spec.jsx
new file mode 100644
index 0000000000000..05e02b92a4b45
--- /dev/null
+++ b/superset/assets/spec/javascripts/explore/components/FilterDefinitionOption_spec.jsx
@@ -0,0 +1,36 @@
+/* eslint-disable no-unused-expressions */
+import React from 'react';
+import { expect } from 'chai';
+import { describe, it } from 'mocha';
+import { shallow } from 'enzyme';
+
+import FilterDefinitionOption from '../../../../src/explore/components/FilterDefinitionOption';
+import ColumnOption from '../../../../src/components/ColumnOption';
+import AdhocMetricStaticOption from '../../../../src/explore/components/AdhocMetricStaticOption';
+import AdhocMetric, { EXPRESSION_TYPES } from '../../../../src/explore/AdhocMetric';
+import { AGGREGATES } from '../../../../src/explore/constants';
+
+const sumValueAdhocMetric = new AdhocMetric({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ column: { type: 'VARCHAR(255)', column_name: 'source' },
+ aggregate: AGGREGATES.SUM,
+});
+
+describe('FilterDefinitionOption', () => {
+ it('renders a ColumnOption given a column', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(ColumnOption)).to.have.lengthOf(1);
+ });
+
+ it('renders a AdhocMetricStaticOption given an adhoc metric', () => {
+ const wrapper = shallow();
+ expect(wrapper.find(AdhocMetricStaticOption)).to.have.lengthOf(1);
+ });
+
+ it('renders the metric name given a saved metric', () => {
+ const wrapper = shallow((
+
+ ));
+ expect(wrapper.text()).to.equal('my_custom_metric');
+ });
+});
diff --git a/superset/assets/src/explore/AdhocFilter.js b/superset/assets/src/explore/AdhocFilter.js
new file mode 100644
index 0000000000000..0c84ef55a96c6
--- /dev/null
+++ b/superset/assets/src/explore/AdhocFilter.js
@@ -0,0 +1,102 @@
+import { MULTI_OPERATORS } from './constants';
+
+export const EXPRESSION_TYPES = {
+ SIMPLE: 'SIMPLE',
+ SQL: 'SQL',
+};
+
+export const CLAUSES = {
+ HAVING: 'HAVING',
+ WHERE: 'WHERE',
+};
+
+const OPERATORS_TO_SQL = {
+ '==': '=',
+ '!=': '<>',
+ '>': '>',
+ '<': '<',
+ '>=': '>=',
+ '<=': '<=',
+ in: 'in',
+ 'not in': 'not in',
+ like: 'like',
+};
+
+function translateToSql(adhocMetric, { useSimple } = {}) {
+ if (adhocMetric.expressionType === EXPRESSION_TYPES.SIMPLE || useSimple) {
+ const isMulti = MULTI_OPERATORS.indexOf(adhocMetric.operator) >= 0;
+ const subject = adhocMetric.subject;
+ const operator = OPERATORS_TO_SQL[adhocMetric.operator];
+ const comparator = isMulti ? adhocMetric.comparator.join("','") : adhocMetric.comparator;
+ return `${subject} ${operator} ${isMulti ? '(\'' : ''}${comparator}${isMulti ? '\')' : ''}`;
+ } else if (adhocMetric.expressionType === EXPRESSION_TYPES.SQL) {
+ return adhocMetric.sqlExpression;
+ }
+ return '';
+}
+
+export default class AdhocFilter {
+ constructor(adhocFilter) {
+ this.expressionType = adhocFilter.expressionType || EXPRESSION_TYPES.SIMPLE;
+ if (this.expressionType === EXPRESSION_TYPES.SIMPLE) {
+ this.subject = adhocFilter.subject;
+ this.operator = adhocFilter.operator;
+ this.comparator = adhocFilter.comparator;
+ this.clause = adhocFilter.clause;
+ this.sqlExpression = null;
+ } else if (this.expressionType === EXPRESSION_TYPES.SQL) {
+ this.sqlExpression = adhocFilter.sqlExpression ||
+ translateToSql(adhocFilter, { useSimple: true });
+ this.clause = adhocFilter.clause;
+ this.subject = null;
+ this.operator = null;
+ this.comparator = null;
+ }
+ this.fromFormData = !!adhocFilter.filterOptionName;
+
+ this.filterOptionName = adhocFilter.filterOptionName ||
+ `filter_${Math.random().toString(36).substring(2, 15)}_${Math.random().toString(36).substring(2, 15)}`;
+ }
+
+ duplicateWith(nextFields) {
+ return new AdhocFilter({
+ ...this,
+ expressionType: this.expressionType,
+ subject: this.subject,
+ operator: this.operator,
+ clause: this.clause,
+ sqlExpression: this.sqlExpression,
+ fromFormData: this.fromFormData,
+ filterOptionName: this.filterOptionName,
+ ...nextFields,
+ });
+ }
+
+ equals(adhocFilter) {
+ return adhocFilter.expressionType === this.expressionType &&
+ adhocFilter.sqlExpression === this.sqlExpression &&
+ adhocFilter.operator === this.operator &&
+ adhocFilter.comparator === this.comparator &&
+ adhocFilter.subject === this.subject;
+ }
+
+ isValid() {
+ if (this.expressionType === EXPRESSION_TYPES.SIMPLE) {
+ return !!(this.operator && this.subject && this.comparator && this.clause);
+ } else if (this.expressionType === EXPRESSION_TYPES.SQL) {
+ return !!(this.sqlExpression && this.clause);
+ }
+ return false;
+ }
+
+ getDefaultLabel() {
+ const label = this.translateToSql();
+ return label.length < 43 ?
+ label :
+ label.substring(0, 40) + '...';
+ }
+
+ translateToSql() {
+ return translateToSql(this);
+ }
+}
diff --git a/superset/assets/src/explore/AdhocMetric.js b/superset/assets/src/explore/AdhocMetric.js
index 5c62f0544f896..e069fd7359453 100644
--- a/superset/assets/src/explore/AdhocMetric.js
+++ b/superset/assets/src/explore/AdhocMetric.js
@@ -50,14 +50,19 @@ export default class AdhocMetric {
}
getDefaultLabel() {
+ const label = this.translateToSql();
+ return label.length < 43 ?
+ label :
+ label.substring(0, 40) + '...';
+ }
+
+ translateToSql() {
if (this.expressionType === EXPRESSION_TYPES.SIMPLE) {
return `${this.aggregate || ''}(${(this.column && this.column.column_name) || ''})`;
} else if (this.expressionType === EXPRESSION_TYPES.SQL) {
- return this.sqlExpression.length < 43 ?
- this.sqlExpression :
- this.sqlExpression.substring(0, 40) + '...';
+ return this.sqlExpression;
}
- return 'malformatted metric';
+ return '';
}
duplicateWith(nextFields) {
diff --git a/superset/assets/src/explore/components/AdhocFilterEditPopover.jsx b/superset/assets/src/explore/components/AdhocFilterEditPopover.jsx
new file mode 100644
index 0000000000000..7439ab3ad2dbe
--- /dev/null
+++ b/superset/assets/src/explore/components/AdhocFilterEditPopover.jsx
@@ -0,0 +1,153 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Button, Popover, Tab, Tabs } from 'react-bootstrap';
+
+import columnType from '../propTypes/columnType';
+import adhocMetricType from '../propTypes/adhocMetricType';
+import AdhocFilter, { EXPRESSION_TYPES } from '../AdhocFilter';
+import AdhocFilterEditPopoverSimpleTabContent from './AdhocFilterEditPopoverSimpleTabContent';
+import AdhocFilterEditPopoverSqlTabContent from './AdhocFilterEditPopoverSqlTabContent';
+
+const propTypes = {
+ adhocFilter: PropTypes.instanceOf(AdhocFilter).isRequired,
+ onChange: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onResize: PropTypes.func.isRequired,
+ options: PropTypes.arrayOf(PropTypes.oneOfType([
+ columnType,
+ PropTypes.shape({ saved_metric_name: PropTypes.string.isRequired }),
+ adhocMetricType,
+ ])).isRequired,
+ datasource: PropTypes.object,
+};
+
+const startingWidth = 300;
+const startingHeight = 190;
+
+export default class AdhocFilterEditPopover extends React.Component {
+ constructor(props) {
+ super(props);
+ this.onSave = this.onSave.bind(this);
+ this.onDragDown = this.onDragDown.bind(this);
+ this.onMouseMove = this.onMouseMove.bind(this);
+ this.onMouseUp = this.onMouseUp.bind(this);
+ this.onAdhocFilterChange = this.onAdhocFilterChange.bind(this);
+
+ this.state = {
+ adhocFilter: this.props.adhocFilter,
+ width: startingWidth,
+ height: startingHeight,
+ };
+ }
+
+ componentDidMount() {
+ document.addEventListener('mouseup', this.onMouseUp);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('mouseup', this.onMouseUp);
+ document.removeEventListener('mousemove', this.onMouseMove);
+ }
+
+ onAdhocFilterChange(adhocFilter) {
+ this.setState({ adhocFilter });
+ }
+
+ onSave() {
+ this.props.onChange(this.state.adhocFilter);
+ this.props.onClose();
+ }
+
+ onDragDown(e) {
+ this.dragStartX = e.clientX;
+ this.dragStartY = e.clientY;
+ this.dragStartWidth = this.state.width;
+ this.dragStartHeight = this.state.height;
+ document.addEventListener('mousemove', this.onMouseMove);
+ }
+
+ onMouseMove(e) {
+ this.props.onResize();
+ this.setState({
+ width: Math.max(this.dragStartWidth + (e.clientX - this.dragStartX), startingWidth),
+ height: Math.max(this.dragStartHeight + (e.clientY - this.dragStartY) * 2, startingHeight),
+ });
+ }
+
+ onMouseUp() {
+ document.removeEventListener('mousemove', this.onMouseMove);
+ }
+
+ render() {
+ const {
+ adhocFilter: propsAdhocFilter,
+ options,
+ onChange,
+ onClose,
+ onResize,
+ datasource,
+ ...popoverProps
+ } = this.props;
+
+ const { adhocFilter } = this.state;
+
+ const stateIsValid = adhocFilter.isValid();
+ const hasUnsavedChanges = !adhocFilter.equals(propsAdhocFilter);
+
+ return (
+
+
+
+
+
+ {
+ (!this.props.datasource || this.props.datasource.type !== 'druid') &&
+
+
+
+ }
+
+
+
+
+
+
+
+ );
+ }
+}
+AdhocFilterEditPopover.propTypes = propTypes;
diff --git a/superset/assets/src/explore/components/AdhocFilterEditPopoverSimpleTabContent.jsx b/superset/assets/src/explore/components/AdhocFilterEditPopoverSimpleTabContent.jsx
new file mode 100644
index 0000000000000..b13fea1bdbcfc
--- /dev/null
+++ b/superset/assets/src/explore/components/AdhocFilterEditPopoverSimpleTabContent.jsx
@@ -0,0 +1,257 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormGroup } from 'react-bootstrap';
+import VirtualizedSelect from 'react-virtualized-select';
+
+import AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../AdhocFilter';
+import adhocMetricType from '../propTypes/adhocMetricType';
+import columnType from '../propTypes/columnType';
+import { t } from '../../locales';
+import {
+ OPERATORS,
+ TABLE_ONLY_OPERATORS,
+ DRUID_ONLY_OPERATORS,
+ HAVING_OPERATORS,
+ MULTI_OPERATORS,
+} from '../constants';
+import FilterDefinitionOption from './FilterDefinitionOption';
+import OnPasteSelect from '../../components/OnPasteSelect';
+import SelectControl from './controls/SelectControl';
+import VirtualizedRendererWrap from '../../components/VirtualizedRendererWrap';
+
+const $ = require('jquery');
+
+const propTypes = {
+ adhocFilter: PropTypes.instanceOf(AdhocFilter).isRequired,
+ onChange: PropTypes.func.isRequired,
+ options: PropTypes.arrayOf(PropTypes.oneOfType([
+ columnType,
+ PropTypes.shape({ saved_metric_name: PropTypes.string.isRequired }),
+ adhocMetricType,
+ ])).isRequired,
+ datasource: PropTypes.object,
+};
+
+const defaultProps = {
+ datasource: {},
+};
+
+function translateOperator(operator) {
+ if (operator === OPERATORS['==']) {
+ return 'equals';
+ } else if (operator === OPERATORS['!=']) {
+ return 'not equal to';
+ }
+ return operator;
+}
+
+export default class AdhocFilterEditPopoverSimpleTabContent extends React.Component {
+ constructor(props) {
+ super(props);
+ this.onSubjectChange = this.onSubjectChange.bind(this);
+ this.onOperatorChange = this.onOperatorChange.bind(this);
+ this.onComparatorChange = this.onComparatorChange.bind(this);
+ this.onInputComparatorChange = this.onInputComparatorChange.bind(this);
+ this.isOperatorRelevant = this.isOperatorRelevant.bind(this);
+ this.refreshComparatorSuggestions = this.refreshComparatorSuggestions.bind(this);
+
+ this.state = {
+ suggestions: [],
+ };
+
+ this.selectProps = {
+ multi: false,
+ name: 'select-column',
+ labelKey: 'label',
+ autosize: false,
+ clearable: false,
+ selectWrap: VirtualizedSelect,
+ };
+ }
+
+ componentWillMount() {
+ this.refreshComparatorSuggestions();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.adhocFilter.subject !== this.props.adhocFilter.subject) {
+ this.refreshComparatorSuggestions();
+ }
+ }
+
+ onSubjectChange(option) {
+ let subject;
+ let clause;
+ // infer the new clause based on what subject was selected.
+ if (option && option.column_name) {
+ subject = option.column_name;
+ clause = CLAUSES.WHERE;
+ } else if (option && (option.saved_metric_name || option.label)) {
+ subject = option.saved_metric_name || option.label;
+ clause = CLAUSES.HAVING;
+ }
+ this.props.onChange(this.props.adhocFilter.duplicateWith({
+ subject,
+ clause,
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ }));
+ }
+
+ onOperatorChange(operator) {
+ const currentComparator = this.props.adhocFilter.comparator;
+ let newComparator;
+ // convert between list of comparators and individual comparators
+ // (e.g. `in ('North America', 'Africa')` to `== 'North America'`)
+ if (MULTI_OPERATORS.indexOf(operator.operator) >= 0) {
+ newComparator = Array.isArray(currentComparator) ?
+ currentComparator :
+ [currentComparator].filter(element => element);
+ } else {
+ newComparator = Array.isArray(currentComparator) ? currentComparator[0] : currentComparator;
+ }
+ this.props.onChange(this.props.adhocFilter.duplicateWith({
+ operator: operator && operator.operator,
+ comparator: newComparator,
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ }));
+ }
+
+ onInputComparatorChange(event) {
+ this.onComparatorChange(event.target.value);
+ }
+
+ onComparatorChange(comparator) {
+ this.props.onChange(this.props.adhocFilter.duplicateWith({
+ comparator,
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ }));
+ }
+
+ refreshComparatorSuggestions() {
+ const datasource = this.props.datasource;
+ const col = this.props.adhocFilter.subject;
+ const having = this.props.adhocFilter.clause === CLAUSES.HAVING;
+
+ if (col && datasource && datasource.filter_select && !having) {
+ 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({ suggestions: data, activeRequest: null }),
+ }),
+ });
+ }
+ }
+
+ isOperatorRelevant(operator) {
+ return !(
+ (this.props.datasource.type === 'druid' && TABLE_ONLY_OPERATORS.indexOf(operator) >= 0) ||
+ (this.props.datasource.type === 'table' && DRUID_ONLY_OPERATORS.indexOf(operator) >= 0) ||
+ (
+ this.props.adhocFilter.clause === CLAUSES.HAVING &&
+ HAVING_OPERATORS.indexOf(operator) === -1
+ )
+ );
+ }
+
+ focusComparator(ref) {
+ if (ref) {
+ ref.focus();
+ }
+ }
+
+ render() {
+ const { adhocFilter, options, datasource } = this.props;
+
+ let subjectSelectProps = {
+ value: adhocFilter.subject ? { value: adhocFilter.subject } : undefined,
+ onChange: this.onSubjectChange,
+ optionRenderer: VirtualizedRendererWrap(option => (
+
+ )),
+ valueRenderer: option => {option.value},
+ valueKey: 'filterOptionName',
+ noResultsText: t('No such column found. To filter on a metric, try the Custom SQL tab.'),
+ };
+
+ if (datasource.type === 'druid') {
+ subjectSelectProps = {
+ ...subjectSelectProps,
+ placeholder: t('%s column(s) and metric(s)', options.length),
+ options,
+ };
+ } else {
+ // we cannot support simple ad-hoc filters for metrics because we don't know what type
+ // the value should be cast to (without knowing the output type of the aggregate, which
+ // becomes a rather complicated problem)
+ subjectSelectProps = {
+ ...subjectSelectProps,
+ placeholder: adhocFilter.clause === CLAUSES.WHERE ?
+ t('%s column(s)', options.length) :
+ t('To filter on a metric, use Custom SQL tab.'),
+ options: options.filter(option => option.column_name),
+ };
+ }
+
+ const operatorSelectProps = {
+ placeholder: t('%s operators(s)', Object.keys(OPERATORS).length),
+ options: Object.keys(OPERATORS).filter(this.isOperatorRelevant).map((
+ operator => ({ operator })
+ )),
+ value: adhocFilter.operator,
+ onChange: this.onOperatorChange,
+ optionRenderer: VirtualizedRendererWrap((
+ operator => translateOperator(operator.operator)
+ )),
+ valueRenderer: operator => (
+
+ {translateOperator(operator.operator)}
+
+ ),
+ valueKey: 'operator',
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {
+ (
+ MULTI_OPERATORS.indexOf(adhocFilter.operator) >= 0 ||
+ this.state.suggestions.length > 0
+ ) ?
+ = 0}
+ freeForm
+ name="filter-comparator-value"
+ value={adhocFilter.comparator}
+ isLoading={false}
+ choices={this.state.suggestions}
+ onChange={this.onComparatorChange}
+ showHeader={false}
+ noResultsText={t('type a value here')}
+ /> :
+
+ }
+
+
+ );
+ }
+}
+AdhocFilterEditPopoverSimpleTabContent.propTypes = propTypes;
+AdhocFilterEditPopoverSimpleTabContent.defaultProps = defaultProps;
diff --git a/superset/assets/src/explore/components/AdhocFilterEditPopoverSqlTabContent.jsx b/superset/assets/src/explore/components/AdhocFilterEditPopoverSqlTabContent.jsx
new file mode 100644
index 0000000000000..8a3a97bd82c06
--- /dev/null
+++ b/superset/assets/src/explore/components/AdhocFilterEditPopoverSqlTabContent.jsx
@@ -0,0 +1,121 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import AceEditor from 'react-ace';
+import 'brace/mode/sql';
+import 'brace/theme/github';
+import 'brace/ext/language_tools';
+import { FormGroup } from 'react-bootstrap';
+import VirtualizedSelect from 'react-virtualized-select';
+
+import { sqlWords } from '../../SqlLab/components/AceEditorWrapper';
+import AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../AdhocFilter';
+import adhocMetricType from '../propTypes/adhocMetricType';
+import columnType from '../propTypes/columnType';
+import OnPasteSelect from '../../components/OnPasteSelect';
+import VirtualizedRendererWrap from '../../components/VirtualizedRendererWrap';
+import { t } from '../../locales';
+
+const propTypes = {
+ adhocFilter: PropTypes.instanceOf(AdhocFilter).isRequired,
+ onChange: PropTypes.func.isRequired,
+ options: PropTypes.arrayOf(PropTypes.oneOfType([
+ columnType,
+ PropTypes.shape({ saved_metric_name: PropTypes.string.isRequired }),
+ adhocMetricType,
+ ])).isRequired,
+ height: PropTypes.number.isRequired,
+};
+
+const langTools = ace.acequire('ace/ext/language_tools');
+
+export default class AdhocFilterEditPopoverSqlTabContent extends React.Component {
+ constructor(props) {
+ super(props);
+ this.onSqlExpressionChange = this.onSqlExpressionChange.bind(this);
+ this.onSqlExpressionClauseChange = this.onSqlExpressionClauseChange.bind(this);
+
+ this.selectProps = {
+ multi: false,
+ name: 'select-column',
+ labelKey: 'label',
+ autosize: false,
+ clearable: false,
+ selectWrap: VirtualizedSelect,
+ };
+
+ if (langTools) {
+ const words = sqlWords.concat(this.props.options.map((option) => {
+ if (option.column_name) {
+ return { name: option.column_name, value: option.column_name, score: 50, meta: 'option' };
+ }
+ return null;
+ }));
+ const completer = {
+ getCompletions: (aceEditor, session, pos, prefix, callback) => {
+ callback(null, words);
+ },
+ };
+ langTools.setCompleters([completer]);
+ }
+ }
+
+ onSqlExpressionClauseChange(clause) {
+ this.props.onChange(this.props.adhocFilter.duplicateWith({
+ clause: clause && clause.clause,
+ expressionType: EXPRESSION_TYPES.SQL,
+ }));
+ }
+
+ onSqlExpressionChange(sqlExpression) {
+ this.props.onChange(this.props.adhocFilter.duplicateWith({
+ sqlExpression,
+ expressionType: EXPRESSION_TYPES.SQL,
+ }));
+ }
+
+ render() {
+ const { adhocFilter, height } = this.props;
+
+ const clauseSelectProps = {
+ placeholder: t('choose WHERE or HAVING...'),
+ options: Object.keys(CLAUSES).map(clause => ({ clause })),
+ value: adhocFilter.clause,
+ onChange: this.onSqlExpressionClauseChange,
+ optionRenderer: VirtualizedRendererWrap(clause => clause.clause),
+ valueRenderer: clause => {clause.clause},
+ valueKey: 'clause',
+ };
+
+ return (
+
+
+
+
+ Where filters by columns.
+ Having filters by metrics.
+
+
+
+
+
+
+ );
+ }
+}
+AdhocFilterEditPopoverSqlTabContent.propTypes = propTypes;
diff --git a/superset/assets/src/explore/components/AdhocFilterOption.jsx b/superset/assets/src/explore/components/AdhocFilterOption.jsx
new file mode 100644
index 0000000000000..eb7a5c16d6370
--- /dev/null
+++ b/superset/assets/src/explore/components/AdhocFilterOption.jsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Label, OverlayTrigger } from 'react-bootstrap';
+
+import AdhocFilterEditPopover from './AdhocFilterEditPopover';
+import AdhocFilter from '../AdhocFilter';
+import columnType from '../propTypes/columnType';
+import adhocMetricType from '../propTypes/adhocMetricType';
+
+const propTypes = {
+ adhocFilter: PropTypes.instanceOf(AdhocFilter).isRequired,
+ onFilterEdit: PropTypes.func.isRequired,
+ options: PropTypes.arrayOf(PropTypes.oneOfType([
+ columnType,
+ PropTypes.shape({ saved_metric_name: PropTypes.string.isRequired }),
+ adhocMetricType,
+ ])).isRequired,
+ datasource: PropTypes.object,
+};
+
+export default class AdhocFilterOption extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.closeFilterEditOverlay = this.closeFilterEditOverlay.bind(this);
+ this.onPopoverResize = this.onPopoverResize.bind(this);
+ this.onOverlayEntered = this.onOverlayEntered.bind(this);
+ this.onOverlayExited = this.onOverlayExited.bind(this);
+ this.state = { overlayShown: !this.props.adhocFilter.fromFormData };
+ }
+
+ onPopoverResize() {
+ this.forceUpdate();
+ }
+
+ onOverlayEntered() {
+ this.setState({ overlayShown: true });
+ }
+
+ onOverlayExited() {
+ this.setState({ overlayShown: false });
+ }
+
+ onMouseDown(e) {
+ e.stopPropagation();
+ }
+
+ closeFilterEditOverlay() {
+ this.refs.overlay.hide();
+ }
+
+ render() {
+ const { adhocFilter } = this.props;
+ const overlay = (
+
+ );
+
+ return (
+
+
+
+ );
+ }
+}
+AdhocFilterOption.propTypes = propTypes;
diff --git a/superset/assets/src/explore/components/AdhocMetricEditPopover.jsx b/superset/assets/src/explore/components/AdhocMetricEditPopover.jsx
index 4fb8032089fa1..24ac5b5fbcd56 100644
--- a/superset/assets/src/explore/components/AdhocMetricEditPopover.jsx
+++ b/superset/assets/src/explore/components/AdhocMetricEditPopover.jsx
@@ -218,13 +218,15 @@ export default class AdhocMetricEditPopover extends React.Component {
diff --git a/superset/assets/src/explore/components/AdhocMetricOption.jsx b/superset/assets/src/explore/components/AdhocMetricOption.jsx
index e7b270e806371..482557a7a7a52 100644
--- a/superset/assets/src/explore/components/AdhocMetricOption.jsx
+++ b/superset/assets/src/explore/components/AdhocMetricOption.jsx
@@ -18,13 +18,24 @@ export default class AdhocMetricOption extends React.PureComponent {
constructor(props) {
super(props);
this.closeMetricEditOverlay = this.closeMetricEditOverlay.bind(this);
+ this.onOverlayEntered = this.onOverlayEntered.bind(this);
+ this.onOverlayExited = this.onOverlayExited.bind(this);
this.onPopoverResize = this.onPopoverResize.bind(this);
+ this.state = { overlayShown: !this.props.adhocMetric.fromFormData };
}
onPopoverResize() {
this.forceUpdate();
}
+ onOverlayEntered() {
+ this.setState({ overlayShown: true });
+ }
+
+ onOverlayExited() {
+ this.setState({ overlayShown: false });
+ }
+
closeMetricEditOverlay() {
this.refs.overlay.hide();
}
@@ -52,11 +63,18 @@ export default class AdhocMetricOption extends React.PureComponent {
rootClose
shouldUpdatePosition
defaultOverlayShown={!adhocMetric.fromFormData}
+ onEntered={this.onOverlayEntered}
+ onExited={this.onOverlayExited}
>
diff --git a/superset/assets/src/explore/components/AdhocMetricStaticOption.jsx b/superset/assets/src/explore/components/AdhocMetricStaticOption.jsx
new file mode 100644
index 0000000000000..bce6493ec38eb
--- /dev/null
+++ b/superset/assets/src/explore/components/AdhocMetricStaticOption.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import ColumnTypeLabel from '../../components/ColumnTypeLabel';
+import adhocMetricType from '../propTypes/adhocMetricType';
+
+const propTypes = {
+ adhocMetric: adhocMetricType,
+ showType: PropTypes.bool,
+};
+
+export default function AdhocMetricStaticOption({ adhocMetric, showType }) {
+ return (
+
+ {showType && }
+
+ {adhocMetric.label}
+
+
+ );
+}
+AdhocMetricStaticOption.propTypes = propTypes;
diff --git a/superset/assets/src/explore/components/Control.jsx b/superset/assets/src/explore/components/Control.jsx
index 25d69a5be46ea..52682dee00222 100644
--- a/superset/assets/src/explore/components/Control.jsx
+++ b/superset/assets/src/explore/components/Control.jsx
@@ -19,6 +19,7 @@ const propTypes = {
validationErrors: PropTypes.array,
renderTrigger: PropTypes.bool,
rightNode: PropTypes.node,
+ formData: PropTypes.object,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
diff --git a/superset/assets/src/explore/components/ControlPanelsContainer.jsx b/superset/assets/src/explore/components/ControlPanelsContainer.jsx
index cb2cd7965cb72..1bf653f9386e5 100644
--- a/superset/assets/src/explore/components/ControlPanelsContainer.jsx
+++ b/superset/assets/src/explore/components/ControlPanelsContainer.jsx
@@ -78,6 +78,7 @@ class ControlPanelsContainer extends React.Component {
value={this.props.form_data[controlName]}
validationErrors={ctrls[controlName].validationErrors}
actions={this.props.actions}
+ formData={ctrls[controlName].provideFormDataToProps ? this.props.form_data : null}
{...this.getControlData(controlName)}
/>
))}
diff --git a/superset/assets/src/explore/components/FilterDefinitionOption.jsx b/superset/assets/src/explore/components/FilterDefinitionOption.jsx
new file mode 100644
index 0000000000000..34355f75d53ed
--- /dev/null
+++ b/superset/assets/src/explore/components/FilterDefinitionOption.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import ColumnOption from '../../components/ColumnOption';
+import ColumnTypeLabel from '../../components/ColumnTypeLabel';
+import AdhocMetricStaticOption from './AdhocMetricStaticOption';
+import columnType from '../propTypes/columnType';
+import adhocMetricType from '../propTypes/adhocMetricType';
+
+const propTypes = {
+ option: PropTypes.oneOfType([
+ columnType,
+ PropTypes.shape({ saved_metric_name: PropTypes.string.isRequired }),
+ adhocMetricType,
+ ]).isRequired,
+};
+
+export default function FilterDefinitionOption({ option }) {
+ if (option.saved_metric_name) {
+ return (
+
+
+
+ {option.saved_metric_name}
+
+
+ );
+ } else if (option.column_name) {
+ return (
+
+ );
+ } else if (option.label) {
+ return (
+
+ );
+ }
+}
+FilterDefinitionOption.propTypes = propTypes;
diff --git a/superset/assets/src/explore/components/controls/AdhocFilterControl.jsx b/superset/assets/src/explore/components/controls/AdhocFilterControl.jsx
new file mode 100644
index 0000000000000..abd8778f405a1
--- /dev/null
+++ b/superset/assets/src/explore/components/controls/AdhocFilterControl.jsx
@@ -0,0 +1,259 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import VirtualizedSelect from 'react-virtualized-select';
+
+import { t } from '../../../locales';
+import ControlHeader from '../ControlHeader';
+import adhocFilterType from '../../propTypes/adhocFilterType';
+import adhocMetricType from '../../propTypes/adhocMetricType';
+import savedMetricType from '../../propTypes/savedMetricType';
+import columnType from '../../propTypes/columnType';
+import AdhocFilter, { CLAUSES, EXPRESSION_TYPES } from '../../AdhocFilter';
+import AdhocMetric from '../../AdhocMetric';
+import { OPERATORS } from '../../constants';
+import VirtualizedRendererWrap from '../../../components/VirtualizedRendererWrap';
+import OnPasteSelect from '../../../components/OnPasteSelect';
+import AdhocFilterOption from '../AdhocFilterOption';
+import FilterDefinitionOption from '../FilterDefinitionOption';
+
+const legacyFilterShape = PropTypes.shape({
+ col: PropTypes.string,
+ op: PropTypes.string,
+ val: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
+});
+
+const propTypes = {
+ name: PropTypes.string,
+ onChange: PropTypes.func,
+ value: PropTypes.arrayOf(adhocFilterType),
+ datasource: PropTypes.object,
+ columns: PropTypes.arrayOf(columnType),
+ savedMetrics: PropTypes.arrayOf(savedMetricType),
+ formData: PropTypes.shape({
+ filters: PropTypes.arrayOf(legacyFilterShape),
+ having: PropTypes.string,
+ having_filters: PropTypes.arrayOf(legacyFilterShape),
+ metric: PropTypes.oneOfType([PropTypes.string, adhocMetricType]),
+ metrics: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, adhocMetricType])),
+ where: PropTypes.string,
+ }),
+};
+
+const defaultProps = {
+ name: '',
+ onChange: () => {},
+ columns: [],
+ savedMetrics: [],
+ formData: {},
+};
+
+function isDictionaryForAdhocFilter(value) {
+ return value && !(value instanceof AdhocFilter) && value.expressionType;
+}
+
+export default class AdhocFilterControl extends React.Component {
+
+ constructor(props) {
+ super(props);
+ this.coerceAdhocFilters = this.coerceAdhocFilters.bind(this);
+ this.optionsForSelect = this.optionsForSelect.bind(this);
+ this.onFilterEdit = this.onFilterEdit.bind(this);
+ this.onChange = this.onChange.bind(this);
+ this.getMetricExpression = this.getMetricExpression.bind(this);
+
+ const filters = this.coerceAdhocFilters(this.props.value, this.props.formData);
+ this.optionRenderer = VirtualizedRendererWrap(option => (
+
+ ));
+ this.valueRenderer = adhocFilter => (
+
+ );
+ this.state = {
+ values: filters,
+ options: this.optionsForSelect(this.props),
+ };
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (
+ this.props.columns !== nextProps.columns ||
+ this.props.formData !== nextProps.formData
+ ) {
+ this.setState({ options: this.optionsForSelect(nextProps) });
+ }
+ if (this.props.value !== nextProps.value) {
+ this.setState({ values: this.coerceAdhocFilters(nextProps.value, nextProps.formData) });
+ }
+ }
+
+ onFilterEdit(changedFilter) {
+ this.props.onChange(this.state.values.map((value) => {
+ if (value.filterOptionName === changedFilter.filterOptionName) {
+ return changedFilter;
+ }
+ return value;
+ }));
+ }
+
+ onChange(opts) {
+ this.props.onChange(opts.map((option) => {
+ if (option.saved_metric_name) {
+ return new AdhocFilter({
+ expressionType: this.props.datasource.type === 'druid' ?
+ EXPRESSION_TYPES.SIMPLE :
+ EXPRESSION_TYPES.SQL,
+ subject: this.props.datasource.type === 'druid' ?
+ option.saved_metric_name :
+ this.getMetricExpression(option.saved_metric_name),
+ operator: OPERATORS['>'],
+ comparator: 0,
+ clause: CLAUSES.HAVING,
+ });
+ } else if (option.label) {
+ return new AdhocFilter({
+ expressionType: this.props.datasource.type === 'druid' ?
+ EXPRESSION_TYPES.SIMPLE :
+ EXPRESSION_TYPES.SQL,
+ subject: this.props.datasource.type === 'druid' ?
+ option.label :
+ new AdhocMetric(option).translateToSql(),
+ operator: OPERATORS['>'],
+ comparator: 0,
+ clause: CLAUSES.HAVING,
+ });
+ } else if (option.column_name) {
+ return new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: option.column_name,
+ operator: OPERATORS['=='],
+ comparator: '',
+ clause: CLAUSES.WHERE,
+ });
+ } else if (option instanceof AdhocFilter) {
+ return option;
+ }
+ return null;
+ }).filter(option => option));
+ }
+
+ getMetricExpression(savedMetricName) {
+ return this.props.savedMetrics.find((
+ savedMetric => savedMetric.metric_name === savedMetricName
+ )).expression;
+ }
+
+ coerceAdhocFilters(propsValues, formData) {
+ // this converts filters from the four legacy filter controls into adhoc filters in the case
+ // someone loads an old slice in the explore view
+ if (propsValues) {
+ return propsValues.map(filter => (
+ isDictionaryForAdhocFilter(filter) ? new AdhocFilter(filter) : filter
+ ));
+ }
+ return [
+ ...(formData.filters || []).map(filter => (
+ new AdhocFilter({
+ subject: filter.col,
+ operator: filter.op,
+ comparator: filter.val,
+ clause: CLAUSES.WHERE,
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ filterOptionName: this.generateConvertedFilterOptionName(),
+ })
+ )),
+ ...(formData.having_filters || []).map(filter => (
+ new AdhocFilter({
+ subject: filter.col,
+ operator: filter.op,
+ comparator: filter.val,
+ clause: CLAUSES.HAVING,
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ filterOptionName: this.generateConvertedFilterOptionName(),
+ })
+ )),
+ ...[
+ formData.where ?
+ new AdhocFilter({
+ sqlExpression: formData.where,
+ clause: CLAUSES.WHERE,
+ expressionType: EXPRESSION_TYPES.SQL,
+ filterOptionName: this.generateConvertedFilterOptionName(),
+ }) :
+ null,
+ ],
+ ...[
+ formData.having ?
+ new AdhocFilter({
+ sqlExpression: formData.having,
+ clause: CLAUSES.HAVING,
+ expressionType: EXPRESSION_TYPES.SQL,
+ filterOptionName: this.generateConvertedFilterOptionName(),
+ }) :
+ null,
+ ],
+ ].filter(option => option);
+ }
+
+ generateConvertedFilterOptionName() {
+ return `form_filter_${Math.random().toString(36).substring(2, 15)}_${Math.random().toString(36).substring(2, 15)}`;
+ }
+
+ optionsForSelect(props) {
+ const options = [
+ ...props.columns,
+ ...[...props.formData.metrics, props.formData.metric].map(metric => (
+ metric && (
+ typeof metric === 'string' ?
+ { saved_metric_name: metric } :
+ new AdhocMetric(metric)
+ )
+ )),
+ ].filter(option => option);
+
+ return options.map((option) => {
+ if (option.saved_metric_name) {
+ return { ...option, filterOptionName: option.saved_metric_name };
+ } else if (option.column_name) {
+ return { ...option, filterOptionName: '_col_' + option.column_name };
+ } else if (option instanceof AdhocMetric) {
+ return { ...option, filterOptionName: '_adhocmetric_' + option.label };
+ }
+ return null;
+ }).sort((a, b) => (
+ (a.saved_metric_name || a.column_name || a.label || '').localeCompare((
+ b.saved_metric_name || b.column_name || b.label
+ ))
+ ));
+ }
+
+ render() {
+ return (
+
+
+
+
+ );
+ }
+}
+
+AdhocFilterControl.propTypes = propTypes;
+AdhocFilterControl.defaultProps = defaultProps;
diff --git a/superset/assets/src/explore/components/controls/SelectControl.jsx b/superset/assets/src/explore/components/controls/SelectControl.jsx
index 16cb95e0945a9..d2f354306885a 100644
--- a/superset/assets/src/explore/components/controls/SelectControl.jsx
+++ b/superset/assets/src/explore/components/controls/SelectControl.jsx
@@ -26,6 +26,7 @@ const propTypes = {
valueKey: PropTypes.string,
options: PropTypes.array,
placeholder: PropTypes.string,
+ noResultsText: PropTypes.string,
};
const defaultProps = {
@@ -43,6 +44,7 @@ const defaultProps = {
optionRenderer: opt => opt.label,
valueRenderer: opt => opt.label,
valueKey: 'value',
+ noResultsText: t('No results found'),
};
export default class SelectControl extends React.PureComponent {
@@ -124,6 +126,7 @@ export default class SelectControl extends React.PureComponent {
onFocus: this.props.onFocus,
optionRenderer: VirtualizedRendererWrap(this.props.optionRenderer),
valueRenderer: this.props.valueRenderer,
+ noResultsText: this.props.noResultsText,
selectComponent: this.props.freeForm ? Creatable : Select,
disabled: this.props.disabled,
};
diff --git a/superset/assets/src/explore/components/controls/index.js b/superset/assets/src/explore/components/controls/index.js
index a7ca4636051ec..81991275eace2 100644
--- a/superset/assets/src/explore/components/controls/index.js
+++ b/superset/assets/src/explore/components/controls/index.js
@@ -18,6 +18,7 @@ import TimeSeriesColumnControl from './TimeSeriesColumnControl';
import ViewportControl from './ViewportControl';
import VizTypeControl from './VizTypeControl';
import MetricsControl from './MetricsControl';
+import AdhocFilterControl from './AdhocFilterControl';
const controlMap = {
AnnotationLayerControl,
@@ -40,5 +41,6 @@ const controlMap = {
ViewportControl,
VizTypeControl,
MetricsControl,
+ AdhocFilterControl,
};
export default controlMap;
diff --git a/superset/assets/src/explore/constants.js b/superset/assets/src/explore/constants.js
index 0a92dfd63bf4a..52395305d7d09 100644
--- a/superset/assets/src/explore/constants.js
+++ b/superset/assets/src/explore/constants.js
@@ -7,6 +7,30 @@ export const AGGREGATES = {
SUM: 'SUM',
};
+export const OPERATORS = {
+ '==': '==',
+ '!=': '!=',
+ '>': '>',
+ '<': '<',
+ '>=': '>=',
+ '<=': '<=',
+ in: 'in',
+ 'not in': 'not in',
+ like: 'like',
+ regex: 'regex',
+};
+
+export const TABLE_ONLY_OPERATORS = [OPERATORS.like];
+export const DRUID_ONLY_OPERATORS = [OPERATORS.regex];
+export const HAVING_OPERATORS = [
+ OPERATORS['=='],
+ OPERATORS['!='],
+ OPERATORS['>'],
+ OPERATORS['<'],
+ OPERATORS['>='],
+ OPERATORS['<='],
+];
+export const MULTI_OPERATORS = [OPERATORS.in, OPERATORS['not in']];
+
export const sqlaAutoGeneratedMetricRegex = /^(LONG|DOUBLE|FLOAT)?(SUM|AVG|MAX|MIN|COUNT)\([A-Z_][A-Z0-9_]*\)$/i;
export const druidAutoGeneratedMetricRegex = /^(LONG|DOUBLE|FLOAT)?(SUM|MAX|MIN|COUNT)\([A-Z_][A-Z0-9_]*\)$/i;
-
diff --git a/superset/assets/src/explore/controls.jsx b/superset/assets/src/explore/controls.jsx
index 8dbba958f0b8e..bc5dc4689d964 100644
--- a/superset/assets/src/explore/controls.jsx
+++ b/superset/assets/src/explore/controls.jsx
@@ -1867,6 +1867,19 @@ export const controls = {
tabOverride: 'data',
},
+ adhoc_filters: {
+ type: 'AdhocFilterControl',
+ label: t('Filters'),
+ default: null,
+ description: '',
+ mapStateToProps: state => ({
+ columns: state.datasource ? state.datasource.columns : [],
+ savedMetrics: state.datasource ? state.datasource.metrics : [],
+ datasource: state.datasource,
+ }),
+ provideFormDataToProps: true,
+ },
+
having_filters: {
type: 'FilterControl',
label: '',
diff --git a/superset/assets/src/explore/main.css b/superset/assets/src/explore/main.css
index 946f21915dde2..40047fa9ccabd 100644
--- a/superset/assets/src/explore/main.css
+++ b/superset/assets/src/explore/main.css
@@ -147,6 +147,14 @@
padding: 4px 4px 4px 4px;
}
+.adhoc-filter-edit-tabs > .nav-tabs {
+ margin-bottom: 8px;
+}
+
+.adhoc-filter-edit-tabs > .nav-tabs > li > a {
+ padding: 4px;
+}
+
.edit-popover-resize {
transform: scaleX(-1);
-moz-transform: scaleX(-1);
@@ -161,3 +169,44 @@
#metrics-edit-popover {
max-width: none;
}
+
+#filter-edit-popover {
+ max-width: none;
+}
+
+.filter-edit-clause-dropdown {
+ width: 120px;
+ margin-right: 5px;
+}
+
+.filter-edit-clause-info {
+ font-size: 10px;
+ padding-left: 5px;
+}
+
+.filter-edit-clause-section {
+ display: inline-flex;
+}
+
+.adhoc-filter-option{
+ cursor: pointer;
+}
+
+.adhoc-filter-sql-editor {
+ border: rgb(187, 187, 187) solid thin;
+}
+
+.label-default {
+ background-color: #808e95;
+ font-weight: normal;
+}
+
+.adhoc-filter-simple-column-dropdown {
+ margin-top: 20px;
+}
+
+.adhoc-label-arrow {
+ font-size: 9px;
+ margin-left: 3px;
+ position: static;
+}
diff --git a/superset/assets/src/explore/propTypes/adhocFilterType.js b/superset/assets/src/explore/propTypes/adhocFilterType.js
new file mode 100644
index 0000000000000..d09e4f81eca19
--- /dev/null
+++ b/superset/assets/src/explore/propTypes/adhocFilterType.js
@@ -0,0 +1,22 @@
+import PropTypes from 'prop-types';
+
+import { OPERATORS } from '../constants';
+import { EXPRESSION_TYPES, CLAUSES } from '../AdhocFilter';
+
+export default PropTypes.oneOfType([
+ PropTypes.shape({
+ expressionType: PropTypes.oneOf([EXPRESSION_TYPES.SIMPLE]).isRequired,
+ clause: PropTypes.oneOf([CLAUSES.HAVING, CLAUSES.WHERE]).isRequired,
+ subject: PropTypes.string.isRequired,
+ operator: PropTypes.oneOf(Object.keys(OPERATORS)).isRequired,
+ comparator: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.arrayOf(PropTypes.string),
+ ]).isRequired,
+ }),
+ PropTypes.shape({
+ expressionType: PropTypes.oneOf([EXPRESSION_TYPES.SQL]).isRequired,
+ clause: PropTypes.oneOf([CLAUSES.WHERE, CLAUSES.HAVING]).isRequired,
+ sqlExpression: PropTypes.string.isRequired,
+ }),
+]);
diff --git a/superset/assets/src/explore/visTypes.js b/superset/assets/src/explore/visTypes.js
index 832c1db715b1f..35e49d4587b60 100644
--- a/superset/assets/src/explore/visTypes.js
+++ b/superset/assets/src/explore/visTypes.js
@@ -61,6 +61,7 @@ export const sections = {
expanded: true,
controlSetRows: [
['metrics'],
+ ['adhoc_filters'],
['groupby'],
['limit', 'timeseries_limit_metric'],
['order_desc', 'contribution'],
@@ -114,6 +115,7 @@ export const visTypes = {
expanded: true,
controlSetRows: [
['metrics'],
+ ['adhoc_filters'],
['groupby'],
['columns'],
['row_limit'],
@@ -160,6 +162,7 @@ export const visTypes = {
expanded: true,
controlSetRows: [
['metrics'],
+ ['adhoc_filters'],
['groupby'],
['limit'],
],
@@ -1123,6 +1126,7 @@ export const visTypes = {
expanded: true,
controlSetRows: [
['metric'],
+ ['adhoc_filters'],
],
},
{
@@ -1149,6 +1153,7 @@ export const visTypes = {
expanded: true,
controlSetRows: [
['metric'],
+ ['adhoc_filters'],
],
},
{
@@ -1718,13 +1723,19 @@ export const visTypes = {
export default visTypes;
+function adhocFilterEnabled(viz) {
+ return viz.controlPanelSections.find((
+ section => section.controlSetRows.find(row => row.find(control => control === 'adhoc_filters'))
+ ));
+}
+
export function sectionsToRender(vizType, datasourceType) {
const viz = visTypes[vizType];
return [].concat(
sections.datasourceAndVizType,
datasourceType === 'table' ? sections.sqlaTimeSeries : sections.druidTimeSeries,
viz.controlPanelSections,
- datasourceType === 'table' ? sections.sqlClause : [],
- datasourceType === 'table' ? sections.filters[0] : sections.filters,
- );
+ !adhocFilterEnabled(viz) && (datasourceType === 'table' ? sections.sqlClause : []),
+ !adhocFilterEnabled(viz) && (datasourceType === 'table' ? sections.filters[0] : sections.filters),
+ ).filter(section => section);
}
diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py
index 079648fe733b5..3cece6ec3436a 100644
--- a/superset/connectors/druid/models.py
+++ b/superset/connectors/druid/models.py
@@ -1246,7 +1246,11 @@ def run_query( # noqa / druid
dict_dims = [x for x in pre_qry_dims if isinstance(x, dict)]
pre_qry['dimensions'] = non_dict_dims + dict_dims
- order_by = metrics[0] if metrics else pre_qry_dims[0]
+ order_by = None
+ if metrics:
+ order_by = utils.get_metric_name(metrics[0])
+ else:
+ order_by = pre_qry_dims[0]
if timeseries_limit_metric:
order_by = timeseries_limit_metric
@@ -1296,7 +1300,10 @@ def run_query( # noqa / druid
'limit': row_limit,
'columns': [{
'dimension': (
- metrics[0] if metrics else dimension_values[0]),
+ utils.get_metric_name(
+ metrics[0],
+ ) if metrics else dimension_values[0]
+ ),
'direction': order_direction,
}],
}
diff --git a/superset/utils.py b/superset/utils.py
index bd3d729f85a54..25d4ef38daeaa 100644
--- a/superset/utils.py
+++ b/superset/utils.py
@@ -827,8 +827,12 @@ def is_adhoc_metric(metric):
)
+def get_metric_name(metric):
+ return metric['label'] if is_adhoc_metric(metric) else metric
+
+
def get_metric_names(metrics):
- return [metric['label'] if is_adhoc_metric(metric) else metric for metric in metrics]
+ return [get_metric_name(metric) for metric in metrics]
def ensure_path_exists(path):
diff --git a/superset/viz.py b/superset/viz.py
index 7eb34c68c639f..1e3fcb5ae55ac 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -253,14 +253,56 @@ def query_obj(self):
# extras are used to query elements specific to a datasource type
# for instance the extra where clause that applies only to Tables
- extras = {
- 'where': form_data.get('where', ''),
- 'having': form_data.get('having', ''),
- 'having_druid': form_data.get('having_filters', []),
- 'time_grain_sqla': form_data.get('time_grain_sqla', ''),
- 'druid_time_origin': form_data.get('druid_time_origin', ''),
- }
- filters = form_data.get('filters', [])
+
+ extras = {}
+ filters = []
+ adhoc_filters = form_data.get('adhoc_filters', None)
+ if adhoc_filters is None:
+ extras = {
+ 'where': form_data.get('where', ''),
+ 'having': form_data.get('having', ''),
+ 'having_druid': form_data.get('having_filters', []),
+ 'time_grain_sqla': form_data.get('time_grain_sqla', ''),
+ 'druid_time_origin': form_data.get('druid_time_origin', ''),
+ }
+ filters = form_data.get('filters', [])
+ elif isinstance(adhoc_filters, list):
+ simple_where_filters = []
+ simple_having_filters = []
+ sql_where_filters = []
+ sql_having_filters = []
+ for adhoc_filter in adhoc_filters:
+ expression_type = adhoc_filter.get('expressionType')
+ clause = adhoc_filter.get('clause')
+ if expression_type == 'SIMPLE':
+ if clause == 'WHERE':
+ simple_where_filters.append({
+ 'col': adhoc_filter.get('subject'),
+ 'op': adhoc_filter.get('operator'),
+ 'val': adhoc_filter.get('comparator'),
+ })
+ elif clause == 'HAVING':
+ simple_having_filters.append({
+ 'col': adhoc_filter.get('subject'),
+ 'op': adhoc_filter.get('operator'),
+ 'val': adhoc_filter.get('comparator'),
+ })
+ elif expression_type == 'SQL':
+ if clause == 'WHERE':
+ sql_where_filters.append(adhoc_filter.get('sqlExpression'))
+ elif clause == 'HAVING':
+ sql_having_filters.append(adhoc_filter.get('sqlExpression'))
+ extras = {
+ 'where': ' AND '.join(['({})'.format(sql) for sql in sql_where_filters]),
+ 'having': ' AND '.join(
+ ['({})'.format(sql) for sql in sql_having_filters],
+ ),
+ 'having_druid': simple_having_filters,
+ 'time_grain_sqla': form_data.get('time_grain_sqla', ''),
+ 'druid_time_origin': form_data.get('druid_time_origin', ''),
+ }
+ filters = simple_where_filters
+
d = {
'granularity': granularity,
'from_dttm': from_dttm,
diff --git a/tests/viz_tests.py b/tests/viz_tests.py
index 1762dc863ab3d..fb56581434ef2 100644
--- a/tests/viz_tests.py
+++ b/tests/viz_tests.py
@@ -164,6 +164,120 @@ def test_get_data_applies_percentage(self):
]
self.assertEqual(expected, data['records'])
+ def test_parse_adhoc_filters(self):
+ form_data = {
+ 'metrics': [{
+ 'expressionType': 'SIMPLE',
+ 'aggregate': 'SUM',
+ 'label': 'SUM(value1)',
+ 'column': {'column_name': 'value1', 'type': 'DOUBLE'},
+ }],
+ 'adhoc_filters': [
+ {
+ 'expressionType': 'SIMPLE',
+ 'clause': 'WHERE',
+ 'subject': 'value2',
+ 'operator': '>',
+ 'comparator': '100',
+ },
+ {
+ 'expressionType': 'SIMPLE',
+ 'clause': 'HAVING',
+ 'subject': 'SUM(value1)',
+ 'operator': '<',
+ 'comparator': '10',
+ },
+ {
+ 'expressionType': 'SQL',
+ 'clause': 'HAVING',
+ 'sqlExpression': 'SUM(value1) > 5',
+ },
+ {
+ 'expressionType': 'SQL',
+ 'clause': 'WHERE',
+ 'sqlExpression': 'value3 in (\'North America\')',
+ },
+ ],
+ }
+ datasource = Mock()
+ test_viz = viz.TableViz(datasource, form_data)
+ query_obj = test_viz.query_obj()
+ self.assertEqual(
+ [{'col': 'value2', 'val': '100', 'op': '>'}],
+ query_obj['filter'],
+ )
+ self.assertEqual(
+ [{'op': '<', 'val': '10', 'col': 'SUM(value1)'}],
+ query_obj['extras']['having_druid'],
+ )
+ self.assertEqual('(value3 in (\'North America\'))', query_obj['extras']['where'])
+ self.assertEqual('(SUM(value1) > 5)', query_obj['extras']['having'])
+
+ def test_adhoc_filters_overwrite_legacy_filters(self):
+ form_data = {
+ 'metrics': [{
+ 'expressionType': 'SIMPLE',
+ 'aggregate': 'SUM',
+ 'label': 'SUM(value1)',
+ 'column': {'column_name': 'value1', 'type': 'DOUBLE'},
+ }],
+ 'adhoc_filters': [
+ {
+ 'expressionType': 'SIMPLE',
+ 'clause': 'WHERE',
+ 'subject': 'value2',
+ 'operator': '>',
+ 'comparator': '100',
+ },
+ {
+ 'expressionType': 'SQL',
+ 'clause': 'WHERE',
+ 'sqlExpression': 'value3 in (\'North America\')',
+ },
+ ],
+ 'having': 'SUM(value1) > 5',
+ }
+ datasource = Mock()
+ test_viz = viz.TableViz(datasource, form_data)
+ query_obj = test_viz.query_obj()
+ self.assertEqual(
+ [{'col': 'value2', 'val': '100', 'op': '>'}],
+ query_obj['filter'],
+ )
+ self.assertEqual(
+ [],
+ query_obj['extras']['having_druid'],
+ )
+ self.assertEqual('(value3 in (\'North America\'))', query_obj['extras']['where'])
+ self.assertEqual('', query_obj['extras']['having'])
+
+ def test_legacy_filters_still_appear_without_adhoc_filters(self):
+ form_data = {
+ 'metrics': [{
+ 'expressionType': 'SIMPLE',
+ 'aggregate': 'SUM',
+ 'label': 'SUM(value1)',
+ 'column': {'column_name': 'value1', 'type': 'DOUBLE'},
+ }],
+ 'having': 'SUM(value1) > 5',
+ 'where': 'value3 in (\'North America\')',
+ 'filters': [{'col': 'value2', 'val': '100', 'op': '>'}],
+ 'having_filters': [{'op': '<', 'val': '10', 'col': 'SUM(value1)'}],
+ }
+ datasource = Mock()
+ test_viz = viz.TableViz(datasource, form_data)
+ query_obj = test_viz.query_obj()
+ self.assertEqual(
+ [{'col': 'value2', 'val': '100', 'op': '>'}],
+ query_obj['filter'],
+ )
+ self.assertEqual(
+ [{'op': '<', 'val': '10', 'col': 'SUM(value1)'}],
+ query_obj['extras']['having_druid'],
+ )
+ self.assertEqual('value3 in (\'North America\')', query_obj['extras']['where'])
+ self.assertEqual('SUM(value1) > 5', query_obj['extras']['having'])
+
@patch('superset.viz.BaseViz.query_obj')
def test_query_obj_merges_percent_metrics(self, super_query_obj):
datasource = Mock()