diff --git a/superset/assets/javascripts/components/PopoverSection.jsx b/superset/assets/javascripts/components/PopoverSection.jsx new file mode 100644 index 0000000000..149036681b --- /dev/null +++ b/superset/assets/javascripts/components/PopoverSection.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import InfoTooltipWithTrigger from './InfoTooltipWithTrigger'; + +const propTypes = { + title: PropTypes.string.isRequired, + isSelected: PropTypes.bool.isRequired, + onSelect: PropTypes.func.isRequired, + info: PropTypes.string, + children: PropTypes.node.isRequired, +}; + +export default function PopoverSection({ title, isSelected, children, onSelect, info }) { + return ( +
+
+ {title}   + {info && + } +   + +
+
+ {children} +
+
); +} +PopoverSection.propTypes = propTypes; diff --git a/superset/assets/javascripts/explore/components/Control.jsx b/superset/assets/javascripts/explore/components/Control.jsx index 4ff5af0138..5c644c38f7 100644 --- a/superset/assets/javascripts/explore/components/Control.jsx +++ b/superset/assets/javascripts/explore/components/Control.jsx @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import BoundsControl from './controls/BoundsControl'; import CheckboxControl from './controls/CheckboxControl'; import DatasourceControl from './controls/DatasourceControl'; +import DateFilterControl from './controls/DateFilterControl'; import FilterControl from './controls/FilterControl'; import HiddenControl from './controls/HiddenControl'; import SelectControl from './controls/SelectControl'; @@ -16,6 +17,7 @@ const controlMap = { BoundsControl, CheckboxControl, DatasourceControl, + DateFilterControl, FilterControl, HiddenControl, SelectControl, diff --git a/superset/assets/javascripts/explore/components/controls/DateFilterControl.jsx b/superset/assets/javascripts/explore/components/controls/DateFilterControl.jsx new file mode 100644 index 0000000000..d1bc335d48 --- /dev/null +++ b/superset/assets/javascripts/explore/components/controls/DateFilterControl.jsx @@ -0,0 +1,218 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Button, ButtonGroup, FormControl, InputGroup, + Label, OverlayTrigger, Popover, Glyphicon, +} from 'react-bootstrap'; +import Select from 'react-select'; +import Datetime from 'react-datetime'; +import 'react-datetime/css/react-datetime.css'; +import moment from 'moment'; + +import ControlHeader from '../ControlHeader'; +import PopoverSection from '../../../components/PopoverSection'; + +const RELATIVE_TIME_OPTIONS = ['ago', 'from now']; +const TIME_GRAIN_OPTIONS = ['seconds', 'minutes', 'days', 'weeks', 'months', 'years']; + +const propTypes = { + animation: PropTypes.bool, + name: PropTypes.string.isRequired, + label: PropTypes.string, + description: PropTypes.string, + onChange: PropTypes.func, + value: PropTypes.string.isRequired, + height: PropTypes.number, +}; + +const defaultProps = { + animation: true, + onChange: () => {}, + value: null, +}; + +export default class DateFilterControl extends React.Component { + constructor(props) { + super(props); + const words = props.value.split(' '); + this.state = { + num: '7', + grain: 'days', + rel: 'ago', + dttm: '', + type: 'free', + free: '', + }; + if (words.length >= 3 && RELATIVE_TIME_OPTIONS.indexOf(words[2]) >= 0) { + this.state.num = words[0]; + this.state.grain = words[1]; + this.state.rel = words[2]; + this.state.type = 'rel'; + } else if (moment(props.value).isValid()) { + this.state.dttm = props.value; + this.state.type = 'fix'; + } else { + this.state.free = props.value; + this.state.type = 'free'; + } + } + onControlChange(target, opt) { + this.setState({ [target]: opt.value }, this.onChange); + } + onNumberChange(event) { + this.setState({ num: event.target.value }, this.onChange); + } + onChange() { + let val; + if (this.state.type === 'rel') { + val = `${this.state.num} ${this.state.grain} ${this.state.rel}`; + } else if (this.state.type === 'fix') { + val = this.state.dttm; + } else if (this.state.type === 'free') { + val = this.state.free; + } + this.props.onChange(val); + } + onFreeChange(event) { + this.setState({ free: event.target.value }, this.onChange); + } + setType(type) { + this.setState({ type }, this.onChange); + } + setValue(val) { + this.setState({ type: 'free', free: val }, this.onChange); + this.close(); + } + setDatetime(dttm) { + this.setState({ dttm: dttm.format().substring(0, 19) }, this.onChange); + } + close() { + this.refs.trigger.hide(); + } + renderPopover() { + return ( + +
+ + + + + + + + + +
+
+ +
+
+ ({ label: s, value: s }))} + onChange={this.onControlChange.bind(this, 'rel')} + /> +
+
+
+ + + +
+ + + + + +
+
+
+ ); + } + render() { + return ( +
+ + + + +
+ ); + } +} + +DateFilterControl.propTypes = propTypes; +DateFilterControl.defaultProps = defaultProps; diff --git a/superset/assets/javascripts/explore/main.css b/superset/assets/javascripts/explore/main.css index 6d1a7bd48b..b068497f78 100644 --- a/superset/assets/javascripts/explore/main.css +++ b/superset/assets/javascripts/explore/main.css @@ -86,3 +86,8 @@ .control-panel-section span.label { display: inline-block; } +.input-inline { + float: left; + display: inline-block; + padding-right: 3px; +} diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx index ce5ee43fb4..554554c162 100644 --- a/superset/assets/javascripts/explore/stores/controls.jsx +++ b/superset/assets/javascripts/explore/stores/controls.jsx @@ -557,37 +557,17 @@ export const controls = { }, since: { - type: 'SelectControl', + type: 'DateFilterControl', freeForm: true, - label: 'Since', + label: 'Until', default: '7 days ago', - choices: formatSelectOptions([ - '1 hour ago', - '12 hours ago', - '1 day ago', - '7 days ago', - '28 days ago', - '90 days ago', - '1 year ago', - '100 year ago', - ]), - description: 'Timestamp from filter. This supports free form typing and ' + - 'natural language as in `1 day ago`, `28 days` or `3 years`', }, until: { - type: 'SelectControl', + type: 'DateFilterControl', freeForm: true, label: 'Until', default: 'now', - choices: formatSelectOptions([ - 'now', - '1 day ago', - '7 days ago', - '28 days ago', - '90 days ago', - '1 year ago', - ]), }, max_bubble_size: { diff --git a/superset/assets/package.json b/superset/assets/package.json index 68b9e7c5f9..3359006b08 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -9,7 +9,7 @@ }, "scripts": { "test": "mocha --require ignore-styles --compilers js:babel-core/register --require spec/helpers/browser.js --recursive spec/**/*_spec.*", - "cover": "babel-node node_modules/.bin/babel-istanbul cover _mocha -- --require spec/helpers/browser.js --recursive spec/**/*_spec.*", + "cover": "babel-node node_modules/.bin/babel-istanbul cover _mocha -- --require ignore-styles spec/helpers/browser.js --recursive spec/**/*_spec.*", "dev": "NODE_ENV=dev webpack --watch --colors --progress --debug --output-pathinfo --devtool inline-source-map", "dev-fast": "NODE_ENV=dev webpack --watch --colors --progress --debug --output-pathinfo --devtool eval-cheap-source-map", "prod": "NODE_ENV=production node --max_old_space_size=4096 ./node_modules/webpack/bin/webpack.js -p --colors --progress", @@ -68,6 +68,7 @@ "react-alert": "^1.0.14", "react-bootstrap": "^0.31.2", "react-bootstrap-table": "^3.1.7", + "react-datetime": "^2.9.0", "react-dom": "^15.5.1", "react-gravatar": "^2.6.1", "react-grid-layout": "^0.14.4", diff --git a/superset/assets/spec/javascripts/components/PopoverSection_spec.jsx b/superset/assets/spec/javascripts/components/PopoverSection_spec.jsx new file mode 100644 index 0000000000..b9a638c8cf --- /dev/null +++ b/superset/assets/spec/javascripts/components/PopoverSection_spec.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { shallow } from 'enzyme'; + +import PopoverSection from '../../../javascripts/components/PopoverSection'; + +describe('PopoverSection', () => { + const defaultProps = { + title: 'Section Title', + isSelected: true, + onSelect: () => {}, + info: 'info section', + children:
, + }; + + let wrapper; + const factory = (overrideProps) => { + const props = Object.assign({}, defaultProps, overrideProps || {}); + return shallow(); + }; + beforeEach(() => { + wrapper = factory(); + }); + it('renders', () => { + expect(React.isValidElement()).to.equal(true); + }); + it('is show an icon when selected', () => { + expect(wrapper.find('.fa-check')).to.have.length(1); + }); + it('is show no icon when not selected', () => { + expect(factory({ isSelected: false }).find('.fa-check')).to.have.length(0); + }); +}); diff --git a/superset/assets/spec/javascripts/explore/components/DateFilterControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/DateFilterControl_spec.jsx new file mode 100644 index 0000000000..e15356e476 --- /dev/null +++ b/superset/assets/spec/javascripts/explore/components/DateFilterControl_spec.jsx @@ -0,0 +1,63 @@ +/* eslint-disable no-unused-expressions */ +import React from 'react'; +import sinon from 'sinon'; +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import { shallow } from 'enzyme'; +import { Button } from 'react-bootstrap'; + +import DateFilterControl from '../../../../javascripts/explore/components/controls/DateFilterControl'; +import ControlHeader from '../../../../javascripts/explore/components/ControlHeader'; + +const defaultProps = { + animation: false, + name: 'date', + onChange: sinon.spy(), + value: '90 days ago', + label: 'date', +}; + +describe('DateFilterControl', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders a ControlHeader', () => { + const controlHeader = wrapper.find(ControlHeader); + expect(controlHeader).to.have.lengthOf(1); + }); + it('renders 3 Buttons', () => { + const label = wrapper.find('.label').first(); + label.simulate('click'); + setTimeout(() => { + expect(wrapper.find(Button)).to.have.length(3); + }, 10); + }); + it('loads the right state', () => { + const label = wrapper.find('.label').first(); + label.simulate('click'); + setTimeout(() => { + expect(wrapper.state().num).to.equal('90'); + }, 10); + }); + it('renders 2 dimmed sections', () => { + const label = wrapper.find('.label').first(); + label.simulate('click'); + setTimeout(() => { + expect(wrapper.find(Button)).to.have.length(3); + }, 10); + }); + it('opens and closes', () => { + const label = wrapper.find('.label').first(); + label.simulate('click'); + setTimeout(() => { + expect(wrapper.find('.popover')).to.have.length(1); + expect(wrapper.find('.ok')).first().simulate('click'); + setTimeout(() => { + expect(wrapper.find('.popover')).to.have.length(0); + }, 10); + }, 10); + }); +}); diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less index 5c0c4d619c..89b5cbc09e 100644 --- a/superset/assets/stylesheets/superset.less +++ b/superset/assets/stylesheets/superset.less @@ -341,3 +341,18 @@ iframe { .Select--multi .Select-value { line-height: 1.2; } +.dimmed { + opacity: 0.5; +} +.pointer { + cursor: pointer; +} +.PopoverSection { + padding-bottom: 10px; +} +.float-left { + float: left; +} +.float-right { + float: right; +} diff --git a/superset/assets/webpack.config.js b/superset/assets/webpack.config.js index 10e41c96bd..8e3177a2ee 100644 --- a/superset/assets/webpack.config.js +++ b/superset/assets/webpack.config.js @@ -18,8 +18,8 @@ const config = { theme: APP_DIR + '/javascripts/theme.js', common: APP_DIR + '/javascripts/common.js', addSlice: ['babel-polyfill', APP_DIR + '/javascripts/addSlice/index.jsx'], - dashboard: ['babel-polyfill', APP_DIR + '/javascripts/dashboard/Dashboard.jsx'], explore: ['babel-polyfill', APP_DIR + '/javascripts/explore/index.jsx'], + dashboard: ['babel-polyfill', APP_DIR + '/javascripts/dashboard/Dashboard.jsx'], sqllab: ['babel-polyfill', APP_DIR + '/javascripts/SqlLab/index.jsx'], welcome: ['babel-polyfill', APP_DIR + '/javascripts/welcome.js'], profile: ['babel-polyfill', APP_DIR + '/javascripts/profile/index.jsx'], diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 6dccdf874a..96ef575986 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -61,10 +61,12 @@ class TableColumn(Model, BaseColumn): def get_time_filter(self, start_dttm, end_dttm): col = self.sqla_col.label('__time') - return and_( - col >= text(self.dttm_sql_literal(start_dttm)), - col <= text(self.dttm_sql_literal(end_dttm)), - ) + l = [] + if start_dttm: + l.append(col >= text(self.dttm_sql_literal(start_dttm))) + if end_dttm: + l.append(col <= text(self.dttm_sql_literal(end_dttm))) + return and_(*l) def get_timestamp_expression(self, time_grain): """Getting the time component of the query""" @@ -364,7 +366,6 @@ class SqlaTable(Model, BaseDatasource): columns=None, form_data=None): """Querying any sqla table from this common interface""" - template_kwargs = { 'from_dttm': from_dttm, 'groupby': groupby, diff --git a/superset/utils.py b/superset/utils.py index cd70e0c066..fe43bc4ec6 100644 --- a/superset/utils.py +++ b/superset/utils.py @@ -198,6 +198,8 @@ def parse_human_datetime(s): >>> year_ago_1 == year_ago_2 True """ + if not s: + return None try: dttm = parse(s) except Exception: diff --git a/superset/viz.py b/superset/viz.py index 7c88aa12f6..b93f874a3e 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -143,18 +143,14 @@ class BaseViz(object): # potential conflicts with column that would be named `from` or `to` since = ( extra_filters.get('__from') or - form_data.get("since") or - config.get("SUPERSET_DEFAULT_SINCE", "1 year ago") + form_data.get("since") ) from_dttm = utils.parse_human_datetime(since) - now = datetime.now() - if from_dttm > now: - from_dttm = now - (from_dttm - now) until = extra_filters.get('__to') or form_data.get("until", "now") to_dttm = utils.parse_human_datetime(until) - if from_dttm > to_dttm: + if from_dttm and to_dttm and from_dttm > to_dttm: raise Exception(_("From date cannot be larger than to date")) # extras are used to query elements specific to a datasource type