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:
parent
0d10cc569d
commit
4fa416347d
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
/* eslint no-console: 0 */
|
||||
|
||||
import $ from 'jquery';
|
||||
|
||||
// This creates an association between an eventName and the ActionLog instance so that
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue