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