blob: 1b26c6d57cd728f491c30c1c1d27eb1ead0b1caa [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { FormControl, FormGroup, InputGroup } from 'react-bootstrap';
import { Tooltip } from 'src/common/components/Tooltip';
import Popover from 'src/common/components/Popover';
import { Select, Input } from 'src/common/components';
import { Radio } from 'src/common/components/Radio';
import Button from 'src/components/Button';
import Datetime from 'react-datetime';
import 'react-datetime/css/react-datetime.css';
import moment from 'moment';
import { t, styled, withTheme } from '@superset-ui/core';
import Tabs from 'src/common/components/Tabs';
import {
buildTimeRangeString,
formatTimeRange,
} from 'src/explore/dateFilterUtils';
import Label from 'src/components/Label';
import './DateFilterControl.less';
import ControlHeader from '../ControlHeader';
import PopoverSection from '../../../components/PopoverSection';
const TYPES = Object.freeze({
DEFAULTS: 'defaults',
CUSTOM_START_END: 'custom_start_end',
CUSTOM_RANGE: 'custom_range',
});
const TABS = Object.freeze({
DEFAULTS: 'defaults',
CUSTOM: 'custom',
});
const RELATIVE_TIME_OPTIONS = Object.freeze({
LAST: 'Last',
NEXT: 'Next',
});
const COMMON_TIME_FRAMES = [
'Last day',
'Last week',
'Last month',
'Last quarter',
'Last year',
'No filter',
];
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()
.utc()
.startOf('day')
.subtract(7, 'days')
.format(MOMENT_FORMAT);
const DEFAULT_UNTIL = moment().utc().startOf('day').format(MOMENT_FORMAT);
const SEPARATOR = ' : ';
const FREEFORM_TOOLTIP = t(
'Superset supports smart date parsing. Strings like `3 weeks ago`, `last sunday`, or ' +
'`2 weeks from now` can be used.',
);
const propTypes = {
animation: PropTypes.bool,
name: PropTypes.string.isRequired,
label: PropTypes.string,
description: PropTypes.string,
onChange: PropTypes.func,
value: PropTypes.string,
height: PropTypes.number,
onOpenDateFilterControl: PropTypes.func,
onCloseDateFilterControl: PropTypes.func,
endpoints: PropTypes.arrayOf(PropTypes.string),
};
const defaultProps = {
animation: true,
onChange: () => {},
value: 'Last week',
onOpenDateFilterControl: () => {},
onCloseDateFilterControl: () => {},
};
function isValidMoment(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);
}
function getStateFromSeparator(value) {
const [since, until] = value.split(SEPARATOR, 2);
return { since, until, type: TYPES.CUSTOM_START_END, tab: TABS.CUSTOM };
}
function getStateFromCommonTimeFrame(value) {
const units = `${value.split(' ')[1]}s`;
let sinceMoment;
if (value === 'No filter') {
sinceMoment = '';
} else if (units === 'years') {
sinceMoment = moment().utc().startOf(units).subtract(1, units);
} else {
sinceMoment = moment().utc().startOf('day').subtract(1, units);
}
return {
tab: TABS.DEFAULTS,
type: TYPES.DEFAULTS,
common: value,
since: sinceMoment === '' ? '' : sinceMoment.format(MOMENT_FORMAT),
until:
sinceMoment === '' ? '' : sinceMoment.add(1, units).format(MOMENT_FORMAT),
};
}
function getStateFromCustomRange(value) {
const [rel, num, grain] = value.split(' ', 3);
let since;
let until;
if (rel === RELATIVE_TIME_OPTIONS.LAST) {
until = moment().utc().startOf('day').format(MOMENT_FORMAT);
since = moment()
.utc()
.startOf('day')
.subtract(num, grain)
.format(MOMENT_FORMAT);
} else {
until = moment().utc().startOf('day').add(num, grain).format(MOMENT_FORMAT);
since = moment().startOf('day').format(MOMENT_FORMAT);
}
return {
tab: TABS.CUSTOM,
type: TYPES.CUSTOM_RANGE,
common: null,
rel,
num,
grain,
since,
until,
};
}
const TimeFramesStyles = styled.div`
.radio {
margin: ${({ theme }) => theme.gridUnit}px 0;
}
`;
const PopoverContentStyles = styled.div`
width: ${({ theme }) => theme.gridUnit * 60}px;
.timeframes-container {
margin-left: ${({ theme }) => theme.gridUnit * 2}px;
}
.relative-timerange-container {
display: flex;
margin-top: ${({ theme }) => theme.gridUnit * 2}px;
}
.timerange-input {
width: ${({ theme }) => theme.gridUnit * 15}px;
margin: 0 ${({ theme }) => theme.gridUnit}px;
}
.datetime {
margin: ${({ theme }) => theme.gridUnit}px 0;
}
.ant-tabs {
overflow: visible;
& > .ant-tabs-content-holder {
overflow: visible;
}
}
`;
class DateFilterControl extends React.Component {
constructor(props) {
super(props);
this.state = {
type: TYPES.DEFAULTS,
tab: TABS.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: TIME_GRAIN_OPTIONS[3],
// distinct start/end values, either ISO or freeform
since: DEFAULT_SINCE,
until: DEFAULT_UNTIL,
// react-datetime has a `closeOnSelect` prop, but it's buggy... so we
// handle the calendar visibility here ourselves
showSinceCalendar: false,
showUntilCalendar: false,
sinceViewMode: 'days',
untilViewMode: 'days',
popoverVisible: false,
};
const { value } = props;
if (value && value.indexOf(SEPARATOR) >= 0) {
this.state = { ...this.state, ...getStateFromSeparator(value) };
} else if (COMMON_TIME_FRAMES.indexOf(value) >= 0) {
this.state = { ...this.state, ...getStateFromCommonTimeFrame(value) };
} else if (value) {
this.state = { ...this.state, ...getStateFromCustomRange(value) };
}
this.close = this.close.bind(this);
this.handleClick = this.handleClick.bind(this);
this.isValidSince = this.isValidSince.bind(this);
this.isValidUntil = this.isValidUntil.bind(this);
this.onEnter = this.onEnter.bind(this);
this.renderInput = this.renderInput.bind(this);
this.setCustomRange = this.setCustomRange.bind(this);
this.setCustomStartEnd = this.setCustomStartEnd.bind(this);
this.setTypeCustomRange = this.setTypeCustomRange.bind(this);
this.setTypeCustomStartEnd = this.setTypeCustomStartEnd.bind(this);
this.toggleCalendar = this.toggleCalendar.bind(this);
this.changeTab = this.changeTab.bind(this);
this.handleVisibleChange = this.handleVisibleChange.bind(this);
}
componentDidMount() {
document.addEventListener('click', this.handleClick);
}
componentWillUnmount() {
document.removeEventListener('click', this.handleClick);
}
onEnter(event) {
if (event.key === 'Enter') {
this.close();
}
}
setCustomRange(key, value) {
const updatedState = { ...this.state, [key]: value };
const combinedValue = [
updatedState.rel,
updatedState.num,
updatedState.grain,
].join(' ');
this.setState(getStateFromCustomRange(combinedValue));
}
setCustomStartEnd(key, value) {
const closeCalendar =
(key === 'since' && this.state.sinceViewMode === 'days') ||
(key === 'until' && this.state.untilViewMode === 'days');
this.setState(prevState => ({
type: TYPES.CUSTOM_START_END,
[key]: typeof value === 'string' ? value : value.format(MOMENT_FORMAT),
showSinceCalendar: prevState.showSinceCalendar && !closeCalendar,
showUntilCalendar: prevState.showUntilCalendar && !closeCalendar,
sinceViewMode: closeCalendar ? 'days' : prevState.sinceViewMode,
untilViewMode: closeCalendar ? 'days' : prevState.untilViewMode,
}));
}
setTypeCustomRange() {
this.setState({ type: TYPES.CUSTOM_RANGE });
}
setTypeCustomStartEnd() {
this.setState({ type: TYPES.CUSTOM_START_END });
}
changeTab() {
const { tab } = this.state;
if (tab === TABS.CUSTOM) {
this.setState({ tab: TABS.DEFAULTS });
} else if (tab === TABS.DEFAULTS) {
this.setState({ tab: TABS.CUSTOM });
}
}
handleClick(e) {
const { target } = e;
// switch to `TYPES.CUSTOM_START_END` when the calendar is clicked
if (this.startEndSectionRef && this.startEndSectionRef.contains(target)) {
this.setTypeCustomStartEnd();
}
// if user click outside popover, popover will hide and we will call onCloseDateFilterControl,
// but need to exclude OverlayTrigger component to avoid handle click events twice.
if (target.getAttribute('name') !== 'popover-trigger') {
if (this.popoverContainer && !this.popoverContainer.contains(target)) {
this.props.onCloseDateFilterControl();
}
}
}
close() {
let val;
if (
this.state.type === TYPES.DEFAULTS ||
this.state.tab === TABS.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.onCloseDateFilterControl();
this.props.onChange(val);
this.setState({
showSinceCalendar: false,
showUntilCalendar: false,
popoverVisible: false,
});
}
isValidSince(date) {
return (
!isValidMoment(this.state.until) ||
date <= moment(this.state.until, MOMENT_FORMAT)
);
}
isValidUntil(date) {
return (
!isValidMoment(this.state.since) ||
date >= moment(this.state.since, MOMENT_FORMAT)
);
}
toggleCalendar(key) {
const nextState = {};
if (key === 'showSinceCalendar') {
nextState.showSinceCalendar = !this.state.showSinceCalendar;
if (!this.state.showSinceCalendar) {
nextState.showUntilCalendar = false;
}
} else if (key === 'showUntilCalendar') {
nextState.showUntilCalendar = !this.state.showUntilCalendar;
if (!this.state.showUntilCalendar) {
nextState.showSinceCalendar = false;
}
}
this.setState(nextState);
}
handleVisibleChange(visible) {
if (visible) {
this.props.onOpenDateFilterControl();
} else {
this.props.onCloseDateFilterControl();
}
this.setState({ popoverVisible: visible });
}
renderInput(props, key) {
return (
<FormGroup>
<InputGroup bsSize="small">
<FormControl
{...props}
type="text"
onKeyPress={this.onEnter}
onFocus={this.setTypeCustomStartEnd}
onClick={() => {}}
/>
<InputGroup.Button onClick={() => this.toggleCalendar(key)}>
<Button>
<i className="fa fa-calendar" />
</Button>
</InputGroup.Button>
</InputGroup>
</FormGroup>
);
}
renderPopover() {
const grainOptions = TIME_GRAIN_OPTIONS.map(grain => (
<Select.Option
key={grain}
value={grain}
active={grain === this.state.grain}
>
{grain}
</Select.Option>
));
const timeFrames = COMMON_TIME_FRAMES.map(timeFrame => {
const nextState = getStateFromCommonTimeFrame(timeFrame);
const timeRange = buildTimeRangeString(nextState.since, nextState.until);
return (
<TimeFramesStyles key={timeFrame}>
<Tooltip
id={`tooltip-${timeFrame}`}
key={timeFrame}
placement="right"
title={formatTimeRange(timeRange, this.props.endpoints)}
>
<div style={{ display: 'inline-block' }}>
<Radio
key={timeFrame.replace(' ', '').toLowerCase()}
checked={this.state.common === timeFrame}
onChange={() => this.setState(nextState)}
>
{timeFrame}
</Radio>
</div>
</Tooltip>
</TimeFramesStyles>
);
});
return (
<PopoverContentStyles
id="filter-popover"
ref={ref => {
this.popoverContainer = ref;
}}
>
<Tabs
defaultActiveKey={this.state.tab === TABS.DEFAULTS ? '1' : '2'}
id="type"
className="time-filter-tabs"
onChange={this.changeTab}
>
<Tabs.TabPane key="1" tab="Defaults" forceRender>
<div className="timeframes-container">
<FormGroup>{timeFrames}</FormGroup>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="2" tab="Custom">
<FormGroup>
<PopoverSection
title="Relative to today"
isSelected={this.state.type === TYPES.CUSTOM_RANGE}
onSelect={this.setTypeCustomRange}
>
<div className="relative-timerange-container clearfix centered">
<Select
value={this.state.rel}
onSelect={value => this.setCustomRange('rel', value)}
onFocus={this.setTypeCustomRange}
>
<Select.Option value={RELATIVE_TIME_OPTIONS.LAST}>
Last
</Select.Option>
<Select.Option value={RELATIVE_TIME_OPTIONS.NEXT}>
Next
</Select.Option>
</Select>
<Input
className="timerange-input"
type="text"
onChange={event =>
this.setCustomRange('num', event.target.value)
}
onFocus={this.setTypeCustomRange}
onPressEnter={this.close}
value={this.state.num}
/>
<Select
value={this.state.grain}
onFocus={this.setTypeCustomRange}
onSelect={value => this.setCustomRange('grain', value)}
dropdownMatchSelectWidth={false}
>
{grainOptions}
</Select>
</div>
</PopoverSection>
<PopoverSection
title="Start / end"
isSelected={this.state.type === TYPES.CUSTOM_START_END}
onSelect={this.setTypeCustomStartEnd}
info={FREEFORM_TOOLTIP}
>
<div
ref={ref => {
this.startEndSectionRef = ref;
}}
>
<InputGroup data-test="date-input-group">
<Datetime
className="datetime"
inputProps={{ 'data-test': 'date-from-input' }}
value={this.state.since}
defaultValue={this.state.since}
viewDate={this.state.since}
onChange={value => this.setCustomStartEnd('since', value)}
isValidDate={this.isValidSince}
onClick={this.setTypeCustomStartEnd}
renderInput={props =>
this.renderInput(props, 'showSinceCalendar')
}
open={this.state.showSinceCalendar}
viewMode={this.state.sinceViewMode}
onViewModeChange={sinceViewMode =>
this.setState({ sinceViewMode })
}
/>
<Datetime
className="datetime"
inputProps={{ 'data-test': 'date-to-input' }}
value={this.state.until}
defaultValue={this.state.until}
viewDate={this.state.until}
onChange={value => this.setCustomStartEnd('until', value)}
isValidDate={this.isValidUntil}
onClick={this.setTypeCustomStartEnd}
renderInput={props =>
this.renderInput(props, 'showUntilCalendar')
}
open={this.state.showUntilCalendar}
viewMode={this.state.untilViewMode}
onViewModeChange={untilViewMode =>
this.setState({ untilViewMode })
}
/>
</InputGroup>
</div>
</PopoverSection>
</FormGroup>
</Tabs.TabPane>
</Tabs>
<div className="clearfix">
<Button
data-test="date-ok-button"
buttonSize="small"
className="float-right ok"
buttonStyle="primary"
onClick={this.close}
>
Ok
</Button>
</div>
</PopoverContentStyles>
);
}
render() {
const timeRange = this.props.value || defaultProps.value;
return (
<div>
<ControlHeader {...this.props} />
<Popover
trigger="click"
placement="right"
content={this.renderPopover()}
visible={this.state.popoverVisible}
onVisibleChange={this.handleVisibleChange}
>
<Label
name="popover-trigger"
className="pointer"
data-test="popover-trigger"
>
{formatTimeRange(timeRange, this.props.endpoints)}
</Label>
</Popover>
</div>
);
}
}
export default withTheme(DateFilterControl);
DateFilterControl.propTypes = propTypes;
DateFilterControl.defaultProps = defaultProps;