Make time filter more usable (#4981)

* Initial work

* WIP

* WIP

* Working

* WIP

* Still WIP

* Frontend done

* Working version

* Migration working

* Migration working

* Fix freeform rerender

* Remove jquery

* Fix filter

* Unit tests and lint

* Fix py.test

* Improve unit tests

* Ensure freeform is computed at init

* Fix lint

* Trying to fix pyfreeze error

* Remove freezegun

* Address comments

* Use tabs instead of pills

* Regroup options

* WIP

* Change type when clicking calendar

* Fix CSS

* Fix JS lint
This commit is contained in:
Beto Dealmeida 2018-07-16 14:27:40 -07:00 committed by Maxime Beauchemin
parent 0d10cc569d
commit 4fa416347d
15 changed files with 23064 additions and 199 deletions

File diff suppressed because it is too large Load Diff

View File

@ -91,6 +91,7 @@
"react-addons-css-transition-group": "^15.6.0",
"react-addons-shallow-compare": "^15.4.2",
"react-bootstrap": "^0.31.5",
"react-bootstrap-datetimepicker": "0.0.22",
"react-bootstrap-slider": "2.1.5",
"react-bootstrap-table": "^4.3.1",
"react-color": "^2.13.8",

View File

@ -200,8 +200,7 @@ export const dashboard = function(state = {}, action) {
let filters = state.filters;
const { sliceId, col, vals, merge, refresh } = action;
const filterKeys = [
'__from',
'__to',
'__time_range',
'__time_col',
'__time_grain',
'__time_origin',

View File

@ -1,20 +1,56 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Button, ButtonGroup, FormControl, InputGroup,
Label, OverlayTrigger, Popover, Glyphicon,
Button,
DropdownButton,
FormControl,
FormGroup,
InputGroup,
Label,
MenuItem,
OverlayTrigger,
Popover,
Radio,
Tab,
Tabs,
} from 'react-bootstrap';
import Select from 'react-select';
import Datetime from 'react-datetime';
import 'react-datetime/css/react-datetime.css';
import DateTimeField from 'react-bootstrap-datetimepicker';
import 'react-bootstrap-datetimepicker/css/bootstrap-datetimepicker.min.css';
import moment from 'moment';
import ControlHeader from '../ControlHeader';
import { t } from '../../../locales';
import PopoverSection from '../../../components/PopoverSection';
const RELATIVE_TIME_OPTIONS = ['ago', 'from now'];
const TYPES = Object.freeze({
DEFAULTS: 'defaults',
CUSTOM_START_END: 'custom_start_end',
CUSTOM_RANGE: 'custom_range',
});
const RELATIVE_TIME_OPTIONS = Object.freeze({
LAST: 'Last',
NEXT: 'Next',
});
const COMMON_TIME_FRAMES = [
'Last day',
'Last week',
'Last month',
'Last quarter',
'Last year',
];
const TIME_GRAIN_OPTIONS = ['seconds', 'minutes', 'hours', 'days', 'weeks', 'months', 'years'];
const MOMENT_FORMAT = 'YYYY-MM-DD[T]HH:mm:ss';
const DEFAULT_SINCE = moment().startOf('day').subtract(7, 'days').format(MOMENT_FORMAT);
const DEFAULT_UNTIL = moment().startOf('day').format(MOMENT_FORMAT);
const INVALID_DATE_MESSAGE = 'Invalid date';
const SEPARATOR = ' : ';
const FREEFORM_TOOLTIP = t(
'Superset supports smart date parsing. Strings like `last sunday` or ' +
'`last october` can be used.',
);
const propTypes = {
animation: PropTypes.bool,
name: PropTypes.string.isRequired,
@ -28,172 +64,300 @@ const propTypes = {
const defaultProps = {
animation: true,
onChange: () => {},
value: '',
value: 'Last week',
};
function isFreeform(s) {
/* Moment sometimes consider invalid dates as valid, eg, "10 years ago" gets
* parsed as "Fri Jan 01 2010 00:00:00" local time. This function does a
* better check by comparing a string with a parse/format roundtrip.
*/
return (s !== moment(s, MOMENT_FORMAT).format(MOMENT_FORMAT));
}
export default class DateFilterControl extends React.Component {
constructor(props) {
super(props);
const value = props.value || '';
const value = props.value || defaultProps.value;
this.state = {
type: TYPES.DEFAULTS,
// default time frames, for convenience
common: COMMON_TIME_FRAMES[0],
// "last 7 days", "next 4 weeks", etc.
rel: RELATIVE_TIME_OPTIONS.LAST,
num: '7',
grain: 'days',
rel: 'ago',
dttm: '',
type: 'free',
free: '',
grain: TIME_GRAIN_OPTIONS[3],
// distinct start/end values, either ISO or freeform
since: DEFAULT_SINCE,
until: DEFAULT_UNTIL,
freeformInputs: {},
};
const words = value.split(' ');
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(value).isValid()) {
this.state.dttm = value;
this.state.type = 'fix';
if (value.indexOf(SEPARATOR) >= 0) {
this.state.type = TYPES.CUSTOM_START_END;
[this.state.since, this.state.until] = value.split(SEPARATOR, 2);
} else {
this.state.free = value;
this.state.type = 'free';
this.state.type = TYPES.DEFAULTS;
if (COMMON_TIME_FRAMES.indexOf(value) >= 0) {
this.state.common = value;
} else {
this.state.common = null;
[this.state.rel, this.state.num, this.state.grain] = value.split(' ', 3);
}
}
this.state.freeformInputs.since = isFreeform(this.state.since);
this.state.freeformInputs.until = isFreeform(this.state.until);
// We need direct access to the state of the `DateTimeField` component
this.dateTimeFieldRefs = {};
this.handleClick = this.handleClick.bind(this);
}
componentDidMount() {
document.addEventListener('click', this.handleClick);
}
componentWillUnmount() {
document.removeEventListener('click', this.handleClick);
}
onEnter(event) {
if (event.key === 'Enter') {
this.close();
}
}
onControlChange(target, opt) {
this.setState({ [target]: opt.value });
setDefaults(timeFrame) {
const nextState = {
type: TYPES.DEFAULTS,
common: timeFrame,
until: moment().startOf('day').format(MOMENT_FORMAT),
};
const units = timeFrame.split(' ')[1] + 's';
nextState.since = moment().startOf('day').subtract(1, units).format(MOMENT_FORMAT);
this.setState(nextState, this.updateRefs);
}
onNumberChange(event) {
this.setState({ num: event.target.value });
setCustomRange(key, value) {
const nextState = { ...this.state, type: TYPES.CUSTOM_RANGE };
if (key !== undefined && value !== undefined) {
nextState[key] = value;
}
if (nextState.rel === RELATIVE_TIME_OPTIONS.LAST) {
nextState.until = moment().startOf('day').format(MOMENT_FORMAT);
nextState.since = moment()
.startOf('day')
.subtract(nextState.num, nextState.grain)
.format(MOMENT_FORMAT);
} else {
nextState.until = moment()
.startOf('day')
.add(nextState.num, nextState.grain)
.format(MOMENT_FORMAT);
nextState.since = moment().startOf('day').format(MOMENT_FORMAT);
}
this.setState(nextState, this.updateRefs);
}
onFreeChange(event) {
this.setState({ free: event.target.value });
setCustomStartEnd(key, value) {
const nextState = {
type: TYPES.CUSTOM_START_END,
freeformInputs: { ...this.state.freeformInputs },
};
if (value === INVALID_DATE_MESSAGE) {
// the DateTimeField component will return `Invalid date` for freeform
// text, so we need to cheat and steal the value from the state
const freeformValue = this.dateTimeFieldRefs[key].state.inputValue;
nextState.freeformInputs[key] = true;
nextState[key] = freeformValue;
} else {
nextState.freeformInputs[key] = false;
nextState[key] = value;
}
this.setState(nextState, this.updateRefs);
}
setType(type) {
this.setState({ type });
handleClick(e) {
// switch to `TYPES.CUSTOM_START_END` when the calendar is clicked
if (this.startEndSectionRef.contains(e.target)) {
this.setState({ type: TYPES.CUSTOM_START_END });
}
}
setValueAndClose(val) {
this.setState({ type: 'free', free: val }, this.close);
}
setDatetime(dttm) {
this.setState({ dttm: dttm.format().substring(0, 19) });
updateRefs() {
/* This is required because the <DateTimeField> component does not accept
* freeform dates as props, since they can't be parsed by `moment`.
*/
this.dateTimeFieldRefs.since.setState({ inputValue: this.state.since });
this.dateTimeFieldRefs.until.setState({ inputValue: this.state.until });
}
close() {
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;
if (this.state.type === TYPES.DEFAULTS) {
val = this.state.common;
} else if (this.state.type === TYPES.CUSTOM_RANGE) {
val = `${this.state.rel} ${this.state.num} ${this.state.grain}`;
} else {
val = [this.state.since, this.state.until].join(SEPARATOR);
}
this.props.onChange(val);
this.refs.trigger.hide();
}
renderPopover() {
const grainOptions = TIME_GRAIN_OPTIONS.map(grain => (
<MenuItem
onSelect={this.setCustomRange.bind(this, 'grain')}
key={grain}
eventKey={grain}
active={grain === this.state.grain}
>
{grain}
</MenuItem>
));
const timeFrames = COMMON_TIME_FRAMES.map(timeFrame => (
<Radio
key={timeFrame.replace(' ', '').toLowerCase()}
checked={this.state.common === timeFrame}
onChange={this.setDefaults.bind(this, timeFrame)}
>
{timeFrame}
</Radio>
));
return (
<Popover id="filter-popover">
<Popover id="filter-popover" placement="top" positionTop={0}>
<div style={{ width: '250px' }}>
<PopoverSection
title="Fixed"
isSelected={this.state.type === 'fix'}
onSelect={this.setType.bind(this, 'fix')}
<Tabs
defaultActiveKey={this.state.type === TYPES.DEFAULTS ? 1 : 2}
id="type"
className="time-filter-tabs"
>
<InputGroup bsSize="small">
<InputGroup.Addon>
<Glyphicon glyph="calendar" />
</InputGroup.Addon>
<Datetime
inputProps={{ className: 'form-control input-sm' }}
dateFormat="YYYY-MM-DD"
defaultValue={this.state.dttm}
onFocus={this.setType.bind(this, 'fix')}
onChange={this.setDatetime.bind(this)}
timeFormat="h:mm:ss"
/>
</InputGroup>
</PopoverSection>
<PopoverSection
title="Relative"
isSelected={this.state.type === 'rel'}
onSelect={this.setType.bind(this, 'rel')}
>
<div className="clearfix">
<div style={{ width: '50px' }} className="input-inline">
<FormControl
onFocus={this.setType.bind(this, 'rel')}
value={this.state.num}
onChange={this.onNumberChange.bind(this)}
bsSize="small"
/>
</div>
<div style={{ width: '95px' }} className="input-inline">
<Select
onFocus={this.setType.bind(this, 'rel')}
value={this.state.grain}
clearable={false}
options={TIME_GRAIN_OPTIONS.map(s => ({ label: s, value: s }))}
onChange={this.onControlChange.bind(this, 'grain')}
/>
</div>
<div style={{ width: '95px' }} className="input-inline">
<Select
value={this.state.rel}
onFocus={this.setType.bind(this, 'rel')}
clearable={false}
options={RELATIVE_TIME_OPTIONS.map(s => ({ label: s, value: s }))}
onChange={this.onControlChange.bind(this, 'rel')}
/>
</div>
</div>
</PopoverSection>
<PopoverSection
title="Free form"
isSelected={this.state.type === 'free'}
onSelect={this.setType.bind(this, 'free')}
info={
'Superset supports smart date parsing. Strings like `last sunday` or ' +
'`last october` can be used.'
}
>
<FormControl
onFocus={this.setType.bind(this, 'free')}
value={this.state.free}
onChange={this.onFreeChange.bind(this)}
bsSize="small"
/>
</PopoverSection>
<Tab eventKey={1} title="Defaults">
<FormGroup>{timeFrames}</FormGroup>
</Tab>
<Tab eventKey={2} title="Custom">
<FormGroup>
<PopoverSection
title="Relative to today"
isSelected={this.state.type === TYPES.CUSTOM_RANGE}
onSelect={this.setCustomRange.bind(this)}
>
<div className="clearfix centered" style={{ marginTop: '12px' }}>
<div style={{ width: '60px', marginTop: '-4px' }} className="input-inline">
<DropdownButton
bsSize="small"
componentClass={InputGroup.Button}
id="input-dropdown-rel"
title={this.state.rel}
onFocus={this.setCustomRange.bind(this)}
>
<MenuItem
onSelect={this.setCustomRange.bind(this, 'rel')}
key={RELATIVE_TIME_OPTIONS.LAST}
eventKey={RELATIVE_TIME_OPTIONS.LAST}
active={this.state.rel === RELATIVE_TIME_OPTIONS.LAST}
>Last
</MenuItem>
<MenuItem
onSelect={this.setCustomRange.bind(this, 'rel')}
key={RELATIVE_TIME_OPTIONS.NEXT}
eventKey={RELATIVE_TIME_OPTIONS.NEXT}
active={this.state.rel === RELATIVE_TIME_OPTIONS.NEXT}
>Next
</MenuItem>
</DropdownButton>
</div>
<div style={{ width: '60px', marginTop: '-4px' }} className="input-inline">
<FormControl
bsSize="small"
type="text"
onChange={event => (
this.setCustomRange.call(this, 'num', event.target.value)
)}
onFocus={this.setCustomRange.bind(this)}
onKeyPress={this.onEnter.bind(this)}
value={this.state.num}
style={{ height: '30px' }}
/>
</div>
<div style={{ width: '90px', marginTop: '-4px' }} className="input-inline">
<DropdownButton
bsSize="small"
componentClass={InputGroup.Button}
id="input-dropdown-grain"
title={this.state.grain}
onFocus={this.setCustomRange.bind(this)}
>
{grainOptions}
</DropdownButton>
</div>
</div>
</PopoverSection>
<PopoverSection
title="Start / end"
isSelected={this.state.type === TYPES.CUSTOM_START_END}
onSelect={this.setCustomStartEnd.bind(this)}
info={FREEFORM_TOOLTIP}
>
<div ref={(ref) => { this.startEndSectionRef = ref; }}>
<InputGroup>
<div style={{ margin: '5px 0' }}>
<DateTimeField
ref={(ref) => { this.dateTimeFieldRefs.since = ref; }}
dateTime={
this.state.freeformInputs.since ?
DEFAULT_SINCE :
this.state.since
}
defaultText={this.state.since}
onChange={this.setCustomStartEnd.bind(this, 'since')}
maxDate={moment(this.state.until, MOMENT_FORMAT)}
format={MOMENT_FORMAT}
inputFormat={MOMENT_FORMAT}
onClick={this.setCustomStartEnd.bind(this)}
inputProps={{
onKeyPress: this.onEnter.bind(this),
onFocus: this.setCustomStartEnd.bind(this),
}}
/>
</div>
<div style={{ margin: '5px 0' }}>
<DateTimeField
ref={(ref) => { this.dateTimeFieldRefs.until = ref; }}
dateTime={
this.state.freeformInputs.until ?
DEFAULT_UNTIL :
this.state.until
}
defaultText={this.state.until}
onChange={this.setCustomStartEnd.bind(this, 'until')}
minDate={moment(this.state.since, MOMENT_FORMAT).add(1, 'days')}
format={MOMENT_FORMAT}
inputFormat={MOMENT_FORMAT}
inputProps={{
onKeyPress: this.onEnter.bind(this),
onFocus: this.setCustomStartEnd.bind(this),
}}
/>
</div>
</InputGroup>
</div>
</PopoverSection>
</FormGroup>
</Tab>
</Tabs>
<div className="clearfix">
<Button
bsSize="small"
className="float-left ok"
className="float-right ok"
bsStyle="primary"
onClick={this.close.bind(this)}
>
Ok
</Button>
<ButtonGroup
className="float-right"
>
<Button
bsSize="small"
className="now"
onClick={this.setValueAndClose.bind(this, 'now')}
>
now
</Button>
<Button
bsSize="small"
className="clear"
onClick={this.setValueAndClose.bind(this, '')}
>
clear
</Button>
</ButtonGroup>
</div>
</div>
</Popover>
);
}
render() {
const value = this.props.value || '';
let value = this.props.value || defaultProps.value;
value = value.split(SEPARATOR).map(v => v.replace('T00:00:00', '') || '∞').join(SEPARATOR);
return (
<div>
<ControlHeader {...this.props} />
@ -206,9 +370,7 @@ export default class DateFilterControl extends React.Component {
placement="right"
overlay={this.renderPopover()}
>
<Label style={{ cursor: 'pointer' }}>
{value.replace('T00:00:00', '') || '∞'}
</Label>
<Label style={{ cursor: 'pointer' }}>{value}</Label>
</OverlayTrigger>
</div>
);

View File

@ -888,18 +888,11 @@ export const controls = {
description: t('Pandas resample fill method'),
},
since: {
time_range: {
type: 'DateFilterControl',
freeForm: true,
label: t('Since'),
default: t('7 days ago'),
},
until: {
type: 'DateFilterControl',
freeForm: true,
label: t('Until'),
default: 'now',
label: t('Time range'),
default: t('Last week'),
},
max_bubble_size: {

View File

@ -211,6 +211,14 @@
position: static;
}
.time-filter-tabs > .nav-tabs {
margin-bottom: 8px;
}
.time-filter-tabs > .nav-tabs > li > a {
padding: 4px;
}
.custom-sql-disabled-message {
color: gray;
font-size: 11px;

View File

@ -13,7 +13,7 @@ export const sections = {
description: t('Time related form attributes'),
controlSetRows: [
['granularity', 'druid_time_origin'],
['since', 'until'],
['time_range'],
],
},
datasourceAndVizType: {
@ -37,7 +37,7 @@ export const sections = {
expanded: true,
controlSetRows: [
['granularity_sqla', 'time_grain_sqla'],
['since', 'until'],
['time_range'],
],
},
annotations: {

View File

@ -1,3 +1,5 @@
/* eslint no-console: 0 */
import $ from 'jquery';
// This creates an association between an eventName and the ActionLog instance so that

View File

@ -18,8 +18,7 @@ import { t } from '../locales';
// maps control names to their key in extra_filters
const timeFilterMap = {
since: '__from',
until: '__to',
time_range: '__time_range',
granularity_sqla: '__time_col',
time_grain_sqla: '__time_grain',
druid_time_origin: '__time_origin',
@ -103,27 +102,17 @@ class FilterBox extends React.Component {
}
render() {
let dateFilter;
const since = '__from';
const until = '__to';
const timeRange = '__time_range';
if (this.props.showDateFilter) {
dateFilter = (
<div className="row space-1">
<div className="col-lg-6 col-xs-12">
<div className="col-lg-12 col-xs-12">
<DateFilterControl
name={since}
label={t('Since')}
description={t('Select starting date')}
onChange={this.changeFilter.bind(this, since)}
value={this.state.selectedValues[since]}
/>
</div>
<div className="col-lg-6 col-xs-12">
<DateFilterControl
name={until}
label={t('Until')}
description={t('Select end date')}
onChange={this.changeFilter.bind(this, until)}
value={this.state.selectedValues[until]}
name={timeRange}
label={t('Time range')}
description={t('Select start and end date')}
onChange={this.changeFilter.bind(this, timeRange)}
value={this.state.selectedValues[timeRange]}
/>
</div>
</div>

View File

@ -1341,6 +1341,12 @@ babel-register@^6.24.1, babel-register@^6.26.0:
mkdirp "^0.5.1"
source-map-support "^0.4.15"
babel-runtime@^5.6.18:
version "5.8.38"
resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-5.8.38.tgz#1c0b02eb63312f5f087ff20450827b425c9d4c19"
dependencies:
core-js "^1.0.0"
babel-runtime@^6.11.6, babel-runtime@^6.18.0, babel-runtime@^6.22.0, babel-runtime@^6.23.0, babel-runtime@^6.26.0:
version "6.26.0"
resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
@ -6031,6 +6037,10 @@ moment-timezone@0.5.5:
version "2.22.2"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.2.tgz#3c257f9839fc0e93ff53149632239eb90783ff66"
moment@^2.8.2:
version "2.22.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.1.tgz#529a2e9bf973f259c9643d237fda84de3a26e8ad"
mousetrap@^1.6.1:
version "1.6.2"
resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.2.tgz#caadd9cf886db0986fb2fee59a82f6bd37527587"
@ -7707,6 +7717,14 @@ react-addons-test-utils@^15.6.2:
version "15.6.2"
resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-15.6.2.tgz#c12b6efdc2247c10da7b8770d185080a7b047156"
react-bootstrap-datetimepicker@0.0.22:
version "0.0.22"
resolved "https://registry.yarnpkg.com/react-bootstrap-datetimepicker/-/react-bootstrap-datetimepicker-0.0.22.tgz#07e448d993157d049ad0876d0f9a3c9c5029d9c5"
dependencies:
babel-runtime "^5.6.18"
classnames "^2.1.2"
moment "^2.8.2"
react-bootstrap-slider@2.1.5:
version "2.1.5"
resolved "https://registry.yarnpkg.com/react-bootstrap-slider/-/react-bootstrap-slider-2.1.5.tgz#2f79e57b69ddf2b5bd23310bddbd2de0c6bdfef3"

View File

@ -82,3 +82,10 @@ def cast_form_data(form_data):
if k not in FORM_DATA_KEY_WHITELIST:
del d[k]
return d
def update_time_range(form_data):
"""Move since and until to time_range."""
form_data['time_range'] = '{} : {}'.format(
form_data.pop('since'), form_data.pop('until'))
return form_data

View File

@ -27,6 +27,7 @@ import zlib
import bleach
import celery
from dateutil.parser import parse
from dateutil.relativedelta import relativedelta
from flask import flash, Markup, render_template
from flask_babel import gettext as __
from flask_caching import Cache
@ -724,8 +725,7 @@ def merge_extra_filters(form_data):
if 'filters' not in form_data:
form_data['filters'] = []
date_options = {
'__from': 'since',
'__to': 'until',
'__time_range': 'time_range',
'__time_col': 'granularity_sqla',
'__time_grain': 'time_grain_sqla',
'__time_origin': 'druid_time_origin',
@ -847,6 +847,75 @@ def ensure_path_exists(path):
raise
def get_since_until(form_data):
"""Return `since` and `until` from form_data.
This functiom supports both reading the keys separately (from `since` and
`until`), as well as the new `time_range` key. Valid formats are:
- ISO 8601
- X days/years/hours/day/year/weeks
- X days/years/hours/day/year/weeks ago
- X days/years/hours/day/year/weeks from now
- freeform
Additionally, for `time_range` (these specify both `since` and `until`):
- Yesterday
- Last week
- Last month
- Last year
- Last X seconds/minutes/hours/days/weeks/months/years
- Next X seconds/minutes/hours/days/weeks/months/years
"""
separator = ' : '
today = parse_human_datetime('today')
common_time_frames = {
'Yesterday': (today - relativedelta(days=1), today),
'Last week': (today - relativedelta(weeks=1), today),
'Last month': (today - relativedelta(months=1), today),
'Last year': (today - relativedelta(years=1), today),
}
if 'time_range' in form_data:
time_range = form_data['time_range']
if separator in time_range:
since, until = time_range.split(separator, 1)
since = parse_human_datetime(since)
until = parse_human_datetime(until)
elif time_range in common_time_frames:
since, until = common_time_frames[time_range]
else:
rel, num, grain = time_range.split()
if rel == 'Last':
since = today - relativedelta(**{grain: int(num)})
until = today
else: # rel == 'Next'
since = today
until = today + relativedelta(**{grain: int(num)})
else:
since = form_data.get('since', '')
if since:
since_words = since.split(' ')
grains = ['days', 'years', 'hours', 'day', 'year', 'weeks']
if len(since_words) == 2 and since_words[1] in grains:
since += ' ago'
since = parse_human_datetime(since)
until = parse_human_datetime(form_data.get('until', 'now'))
return since, until
def since_until_to_time_range(form_data):
if 'time_range' in form_data:
return
since = form_data.get('since', '')
until = form_data.get('until', 'now')
form_data['time_range'] = ' : '.join((since, until))
def split_adhoc_filters_into_base_filters(fd):
"""
Mutates form data to restructure the adhoc filters in the form of the four base

View File

@ -42,7 +42,7 @@ from superset.connectors.sqla.models import AnnotationDatasource, SqlaTable
from superset.exceptions import SupersetException, SupersetSecurityException
from superset.forms import CsvToDatabaseForm
from superset.jinja_context import get_template_processor
from superset.legacy import cast_form_data
from superset.legacy import cast_form_data, update_time_range
import superset.models.core as models
from superset.models.sql_lab import Query
from superset.sql_parse import SupersetQuery
@ -1312,6 +1312,10 @@ class Superset(BaseSupersetView):
datasource_type,
datasource.name)
# update to new time filter
if 'since' in form_data and 'until' in form_data:
form_data = update_time_range(form_data)
standalone = request.args.get('standalone') == 'true'
bootstrap_data = {
'can_add': slice_add_perm,

View File

@ -66,6 +66,9 @@ class BaseViz(object):
def __init__(self, datasource, form_data, force=False):
if not datasource:
raise Exception(_('Viz is missing a datasource'))
utils.since_until_to_time_range(form_data)
self.datasource = datasource
self.request = request
self.viz_type = form_data.get('viz_type')
@ -258,21 +261,9 @@ class BaseViz(object):
# default order direction
order_desc = form_data.get('order_desc', True)
since = form_data.get('since', '')
until = form_data.get('until', 'now')
since, until = utils.get_since_until(form_data)
time_shift = form_data.get('time_shift', '')
# Backward compatibility hack
if since:
since_words = since.split(' ')
grains = ['days', 'years', 'hours', 'day', 'year', 'weeks']
if (len(since_words) == 2 and since_words[1] in grains):
since += ' ago'
self.time_shift = utils.parse_human_timedelta(time_shift)
since = utils.parse_human_datetime(since)
until = utils.parse_human_datetime(until)
from_dttm = None if since is None else (since - self.time_shift)
to_dttm = None if until is None else (until - self.time_shift)
if from_dttm and to_dttm and from_dttm > to_dttm:
@ -783,8 +774,7 @@ class CalHeatmapViz(BaseViz):
for obj in records
}
start = utils.parse_human_datetime(form_data.get('since'))
end = utils.parse_human_datetime(form_data.get('until'))
start, end = utils.get_since_until(form_data)
if not start or not end:
raise Exception('Please provide both time bounds (Since and Until)')
domain = form_data.get('domain_granularity')

View File

@ -14,12 +14,43 @@ import numpy
from superset.exceptions import SupersetException
from superset.utils import (
base_json_conv, datetime_f, json_int_dttm_ser, json_iso_dttm_ser,
JSONEncodedDict, memoized, merge_extra_filters, merge_request_params,
parse_human_timedelta, validate_json, zlib_compress, zlib_decompress_to_string,
base_json_conv,
datetime_f,
get_since_until,
json_int_dttm_ser,
json_iso_dttm_ser,
JSONEncodedDict,
memoized,
merge_extra_filters,
merge_request_params,
parse_human_timedelta,
validate_json,
zlib_compress,
zlib_decompress_to_string,
)
def mock_parse_human_datetime(s):
if s in ['now', 'today']:
return datetime(2016, 11, 7)
elif s == 'yesterday':
return datetime(2016, 11, 6)
elif s == 'tomorrow':
return datetime(2016, 11, 8)
elif s == 'Last year':
return datetime(2015, 11, 7)
elif s == 'Last 5 months':
return datetime(2016, 6, 7)
elif s == 'Next 5 months':
return datetime(2017, 4, 7)
elif s in ['5 days', '5 days ago']:
return datetime(2016, 11, 2)
elif s == '2018-01-01T00:00:00':
return datetime(2018, 1, 1)
elif s == '2018-12-31T23:59:59':
return datetime(2018, 12, 31, 23, 59, 59)
class UtilsTestCase(unittest.TestCase):
def test_json_int_dttm_ser(self):
dttm = datetime(2020, 1, 1)
@ -98,8 +129,7 @@ class UtilsTestCase(unittest.TestCase):
self.assertEquals(form_data, expected)
# adds extra filters to existing filters and sets time options
form_data = {'extra_filters': [
{'col': '__from', 'op': 'in', 'val': '1 year ago'},
{'col': '__to', 'op': 'in', 'val': None},
{'col': '__time_range', 'op': 'in', 'val': '1 year ago :'},
{'col': '__time_col', 'op': 'in', 'val': 'birth_year'},
{'col': '__time_grain', 'op': 'in', 'val': 'years'},
{'col': 'A', 'op': 'like', 'val': 'hello'},
@ -108,7 +138,7 @@ class UtilsTestCase(unittest.TestCase):
]}
expected = {
'filters': [{'col': 'A', 'op': 'like', 'val': 'hello'}],
'since': '1 year ago',
'time_range': '1 year ago :',
'granularity_sqla': 'birth_year',
'time_grain_sqla': 'years',
'granularity': '90 seconds',
@ -331,3 +361,50 @@ class UtilsTestCase(unittest.TestCase):
result8 = instance.test_method(1, 2, 3)
self.assertEqual(instance.watcher, 4)
self.assertEqual(result1, result8)
@patch('superset.utils.parse_human_datetime', mock_parse_human_datetime)
def test_get_since_until(self):
form_data = {}
result = get_since_until(form_data)
expected = None, datetime(2016, 11, 7)
self.assertEqual(result, expected)
form_data = {'time_range': ' : now'}
result = get_since_until(form_data)
expected = None, datetime(2016, 11, 7)
self.assertEqual(result, expected)
form_data = {'time_range': 'yesterday : tomorrow'}
result = get_since_until(form_data)
expected = datetime(2016, 11, 6), datetime(2016, 11, 8)
self.assertEqual(result, expected)
form_data = {'time_range': '2018-01-01T00:00:00 : 2018-12-31T23:59:59'}
result = get_since_until(form_data)
expected = datetime(2018, 1, 1), datetime(2018, 12, 31, 23, 59, 59)
self.assertEqual(result, expected)
form_data = {'time_range': 'Last year'}
result = get_since_until(form_data)
expected = datetime(2015, 11, 7), datetime(2016, 11, 7)
self.assertEqual(result, expected)
form_data = {'time_range': 'Last 5 months'}
result = get_since_until(form_data)
expected = datetime(2016, 6, 7), datetime(2016, 11, 7)
self.assertEqual(result, expected)
form_data = {'time_range': 'Next 5 months'}
result = get_since_until(form_data)
expected = datetime(2016, 11, 7), datetime(2017, 4, 7)
self.assertEqual(result, expected)
form_data = {'since': '5 days'}
result = get_since_until(form_data)
expected = datetime(2016, 11, 2), datetime(2016, 11, 7)
self.assertEqual(result, expected)
form_data = {'since': '5 days ago', 'until': 'tomorrow'}
result = get_since_until(form_data)
expected = datetime(2016, 11, 2), datetime(2016, 11, 8)
self.assertEqual(result, expected)