diff --git a/superset/assets/package.json b/superset/assets/package.json
index ceb34c74a2eb8..c4911b3101bde 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -74,6 +74,7 @@
"moment": "^2.20.1",
"mousetrap": "^1.6.1",
"mustache": "^2.2.1",
+ "npm": "^5.7.1",
"nvd3": "1.8.6",
"object.entries": "^1.0.4",
"object.keys": "^0.1.0",
diff --git a/superset/assets/spec/javascripts/explore/components/FilterControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/FilterControl_spec.jsx
index 69f8b2794f1f0..2b83fff457979 100644
--- a/superset/assets/spec/javascripts/explore/components/FilterControl_spec.jsx
+++ b/superset/assets/spec/javascripts/explore/components/FilterControl_spec.jsx
@@ -225,12 +225,13 @@ describe('FilterControl', () => {
wrapper.instance().fetchFilterValues(0, 'col1');
expect(wrapper.state().activeRequest).to.equal(spyReq);
// Sets active to null after success
- $.ajax.getCall(0).args[0].success('choices');
+ $.ajax.getCall(0).args[0].success(['opt1', 'opt2', null, '']);
expect(wrapper.state().filters[0].valuesLoading).to.equal(false);
- expect(wrapper.state().filters[0].valueChoices).to.equal('choices');
+ expect(wrapper.state().filters[0].valueChoices).to.deep.equal(['opt1', 'opt2', null, '']);
expect(wrapper.state().activeRequest).to.equal(null);
});
+
it('cancels active request if another is submitted', () => {
const spyReq = sinon.spy();
spyReq.abort = sinon.spy();
diff --git a/superset/assets/spec/javascripts/explore/components/Filter_spec.jsx b/superset/assets/spec/javascripts/explore/components/Filter_spec.jsx
index 1a4c395807ec2..27425de9a3f6f 100644
--- a/superset/assets/spec/javascripts/explore/components/Filter_spec.jsx
+++ b/superset/assets/spec/javascripts/explore/components/Filter_spec.jsx
@@ -46,7 +46,7 @@ describe('Filter', () => {
expect(wrapper.find(Select)).to.have.lengthOf(2);
expect(wrapper.find(Button)).to.have.lengthOf(1);
expect(wrapper.find(SelectControl)).to.have.lengthOf(1);
- expect(wrapper.find('#select-op').prop('options')).to.have.lengthOf(8);
+ expect(wrapper.find('#select-op').prop('options')).to.have.lengthOf(10);
});
it('renders five op choices for table datasource', () => {
@@ -58,7 +58,7 @@ describe('Filter', () => {
filterable_cols: ['country_name'],
};
const druidWrapper = shallow();
- expect(druidWrapper.find('#select-op').prop('options')).to.have.lengthOf(9);
+ expect(druidWrapper.find('#select-op').prop('options')).to.have.lengthOf(11);
});
it('renders six op choices for having filter', () => {
diff --git a/superset/assets/spec/javascripts/utils/common_spec.jsx b/superset/assets/spec/javascripts/utils/common_spec.jsx
index 5aa4b4334f43b..861c38ae530c8 100644
--- a/superset/assets/spec/javascripts/utils/common_spec.jsx
+++ b/superset/assets/spec/javascripts/utils/common_spec.jsx
@@ -1,6 +1,6 @@
import { it, describe } from 'mocha';
import { expect } from 'chai';
-import { isTruthy } from '../../../src/utils/common';
+import { isTruthy, optionFromValue } from '../../../src/utils/common';
describe('utils/common', () => {
describe('isTruthy', () => {
@@ -40,4 +40,14 @@ describe('utils/common', () => {
expect(isTruthy('false')).to.equal(false);
});
});
+ describe('optionFromValue', () => {
+ it('converts values as expected', () => {
+ expect(optionFromValue(false)).to.deep.equal({ value: false, label: '' });
+ expect(optionFromValue(true)).to.deep.equal({ value: true, label: '' });
+ expect(optionFromValue(null)).to.deep.equal({ value: '', label: '' });
+ expect(optionFromValue('')).to.deep.equal({ value: '', label: '' });
+ expect(optionFromValue('foo')).to.deep.equal({ value: 'foo', label: 'foo' });
+ expect(optionFromValue(5)).to.deep.equal({ value: 5, label: '5' });
+ });
+ });
});
diff --git a/superset/assets/src/SqlLab/actions.js b/superset/assets/src/SqlLab/actions.js
index 04a9a5eae4c4b..644947023bcb8 100644
--- a/superset/assets/src/SqlLab/actions.js
+++ b/superset/assets/src/SqlLab/actions.js
@@ -2,6 +2,7 @@
import shortid from 'shortid';
import { now } from '../modules/dates';
import { t } from '../locales';
+import { COMMON_ERR_MESSAGES } from '../common';
const $ = require('jquery');
@@ -163,7 +164,7 @@ export function runQuery(query) {
}
}
if (msg.indexOf('CSRF token') > 0) {
- msg = t('Your session timed out, please refresh your page and try again.');
+ msg = COMMON_ERR_MESSAGES.SESSION_TIMED_OUT;
}
dispatch(queryFailed(query, msg));
},
diff --git a/superset/assets/src/chart/chartAction.js b/superset/assets/src/chart/chartAction.js
index b9338a91f5559..a2f01165d61ce 100644
--- a/superset/assets/src/chart/chartAction.js
+++ b/superset/assets/src/chart/chartAction.js
@@ -1,6 +1,7 @@
import { getExploreUrlAndPayload, getAnnotationJsonUrl } from '../explore/exploreUtils';
import { requiresQuery, ANNOTATION_SOURCE_TYPES } from '../modules/AnnotationTypes';
import { Logger, LOG_ACTIONS_LOAD_EVENT } from '../logger';
+import { COMMON_ERR_MESSAGES } from '../common';
const $ = window.$ = require('jquery');
@@ -160,12 +161,16 @@ export function runQuery(formData, force = false, timeout = 60, key) {
errObject = err.responseJSON;
} else if (err.stack) {
errObject = {
- error: 'Unexpected error: ' + err.description,
+ error: t('Unexpected error: ') + err.description,
stacktrace: err.stack,
};
+ } else if (err.responseText && err.responseText.indexOf('CSRF') >= 0) {
+ errObject = {
+ error: COMMON_ERR_MESSAGES.SESSION_TIMED_OUT,
+ };
} else {
errObject = {
- error: 'Unexpected error.',
+ error: t('Unexpected error.'),
};
}
dispatch(chartUpdateFailed(errObject, key));
diff --git a/superset/assets/src/common.js b/superset/assets/src/common.js
index d84f064065e82..cc509eb892e8b 100644
--- a/superset/assets/src/common.js
+++ b/superset/assets/src/common.js
@@ -1,5 +1,6 @@
/* eslint-disable global-require */
import $ from 'jquery';
+import { t } from './locales';
const utils = require('./modules/utils');
@@ -30,3 +31,8 @@ export function appSetup() {
window.jQuery = $;
require('bootstrap');
}
+
+// Error messages used in many places across applications
+export const COMMON_ERR_MESSAGES = {
+ SESSION_TIMED_OUT: t('Your session timed out, please refresh your page and try again.'),
+};
diff --git a/superset/assets/src/components/AlteredSliceTag.jsx b/superset/assets/src/components/AlteredSliceTag.jsx
index eb24424e8d41f..ad1356b48b0c5 100644
--- a/superset/assets/src/components/AlteredSliceTag.jsx
+++ b/superset/assets/src/components/AlteredSliceTag.jsx
@@ -61,7 +61,7 @@ export default class AlteredSliceTag extends React.Component {
return '[]';
}
return value.map((v) => {
- const filterVal = v.val.constructor === Array ? `[${v.val.join(', ')}]` : v.val;
+ const filterVal = v.val && v.val.constructor === Array ? `[${v.val.join(', ')}]` : v.val;
return `${v.col} ${v.op} ${filterVal}`;
}).join(', ');
} else if (controls[key] && controls[key].type === 'BoundsControl') {
diff --git a/superset/assets/src/explore/components/controls/Filter.jsx b/superset/assets/src/explore/components/controls/Filter.jsx
index 49a9751f3be00..539a4133aed88 100644
--- a/superset/assets/src/explore/components/controls/Filter.jsx
+++ b/superset/assets/src/explore/components/controls/Filter.jsx
@@ -2,8 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import Select from 'react-select';
import { Button, Row, Col } from 'react-bootstrap';
-import SelectControl from './SelectControl';
import { t } from '../../../locales';
+import SelectControl from './SelectControl';
const operatorsArr = [
{ val: 'in', type: 'array', useSelect: true, multi: true },
@@ -16,6 +16,8 @@ const operatorsArr = [
{ 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) => {
@@ -90,6 +92,10 @@ export default class Filter extends React.Component {
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 (
diff --git a/superset/assets/src/utils/common.js b/superset/assets/src/utils/common.js
index f2c3bd24ceff7..093882aa903c0 100644
--- a/superset/assets/src/utils/common.js
+++ b/superset/assets/src/utils/common.js
@@ -103,3 +103,30 @@ export function isTruthy(obj) {
}
return !!obj;
}
+
+export function optionLabel(opt) {
+ if (opt === null) {
+ return '';
+ } else if (opt === '') {
+ return '';
+ } else if (opt === true) {
+ return '';
+ } else if (opt === false) {
+ return '';
+ } else if (typeof opt !== 'string' && opt.toString) {
+ return opt.toString();
+ }
+ return opt;
+}
+
+export function optionValue(opt) {
+ if (opt === null) {
+ return '';
+ }
+ return opt;
+}
+
+export function optionFromValue(opt) {
+ // From a list of options, handles special values & labels
+ return { value: optionValue(opt), label: optionLabel(opt) };
+}
diff --git a/superset/connectors/base/models.py b/superset/connectors/base/models.py
index 68a020e36a9d3..8e4a2a22459d4 100644
--- a/superset/connectors/base/models.py
+++ b/superset/connectors/base/models.py
@@ -6,6 +6,7 @@
import json
+from past.builtins import basestring
from sqlalchemy import (
and_, Boolean, Column, Integer, String, Text,
)
@@ -185,6 +186,35 @@ def data(self):
'verbose_map': verbose_map,
}
+ @staticmethod
+ def filter_values_handler(
+ values, target_column_is_numeric=False, is_list_target=False):
+ def handle_single_value(v):
+ # backward compatibility with previous