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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+ 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