feat(explore): Time picker enhancement follow up (#12208)

* refactor layout

* WIP

* fix typing

* styling

* fix lint

* frontend IT

* rename variable

* added quarter

* typos

* refine code structure
This commit is contained in:
Yongjie Zhao 2021-01-05 10:29:17 +08:00 committed by GitHub
parent 811e1b273b
commit b349edef29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 949 additions and 716 deletions

View File

@ -128,7 +128,7 @@ describe('Time range filter', () => {
cy.route('POST', '/superset/explore_json/**').as('postJson');
});
it('Defaults to the correct tab for time_range params', () => {
it('Advanced time_range params', () => {
const formData = {
...FORM_DATA_DEFAULTS,
metrics: [NUM_METRIC],
@ -142,18 +142,98 @@ describe('Time range filter', () => {
cy.get('[data-test=time-range-trigger]')
.click()
.then(() => {
cy.get('.ant-modal-footer')
.find('button')
.its('length')
.should('eq', 3);
cy.get('.ant-modal-body').within(() => {
cy.get('.footer').find('button').its('length').should('eq', 2);
cy.get('.ant-popover-content').within(() => {
cy.get('input[value="100 years ago"]');
cy.get('input[value="now"]');
});
cy.get('[data-test=modal-cancel-button]').click();
cy.get('[data-test=time-range-modal]').should('not.be.visible');
cy.get('[data-test=cancel-button]').click();
cy.get('.ant-popover').should('not.be.visible');
});
});
it('Common time_range params', () => {
const formData = {
...FORM_DATA_DEFAULTS,
metrics: [NUM_METRIC],
viz_type: 'line',
time_range: 'Last year',
};
cy.visitChartByParams(JSON.stringify(formData));
cy.verifySliceSuccess({ waitAlias: '@postJson' });
cy.get('[data-test=time-range-trigger]')
.click()
.then(() => {
cy.get('.ant-radio-group').children().its('length').should('eq', 5);
cy.get('.ant-radio-checked + span').contains('last year');
cy.get('[data-test=cancel-button]').click();
});
});
it('Previous time_range params', () => {
const formData = {
...FORM_DATA_DEFAULTS,
metrics: [NUM_METRIC],
viz_type: 'line',
time_range:
'DATETRUNC(DATEADD(DATETIME("TODAY"), -1, MONTH), MONTH) : LASTDAY(DATEADD(DATETIME("TODAY"), -1, MONTH), MONTH)',
};
cy.visitChartByParams(JSON.stringify(formData));
cy.verifySliceSuccess({ waitAlias: '@postJson' });
cy.get('[data-test=time-range-trigger]')
.click()
.then(() => {
cy.get('.ant-radio-group').children().its('length').should('eq', 3);
cy.get('.ant-radio-checked + span').contains('previous calendar month');
cy.get('[data-test=cancel-button]').click();
});
});
it('Custom time_range params', () => {
const formData = {
...FORM_DATA_DEFAULTS,
metrics: [NUM_METRIC],
viz_type: 'line',
time_range: 'DATEADD(DATETIME("today"), -7, day) : today',
};
cy.visitChartByParams(JSON.stringify(formData));
cy.verifySliceSuccess({ waitAlias: '@postJson' });
cy.get('[data-test=time-range-trigger]')
.click()
.then(() => {
cy.get('[data-test=custom-frame]').then(() => {
cy.get('.ant-input-number-input-wrap > input')
.invoke('attr', 'value')
.should('eq', '7');
});
cy.get('[data-test=cancel-button]').click();
});
});
it('No filter time_range params', () => {
const formData = {
...FORM_DATA_DEFAULTS,
metrics: [NUM_METRIC],
viz_type: 'line',
time_range: 'No filter',
};
cy.visitChartByParams(JSON.stringify(formData));
cy.verifySliceSuccess({ waitAlias: '@postJson' });
cy.get('[data-test=time-range-trigger]')
.click()
.then(() => {
cy.get('[data-test=no-filter]');
});
cy.get('[data-test=cancel-button]').click();
});
});
describe('Groupby control', () => {

View File

@ -18,7 +18,6 @@
*/
import React, { useState, useEffect } from 'react';
import rison from 'rison';
import moment, { Moment } from 'moment';
import {
SupersetClient,
styled,
@ -29,252 +28,34 @@ import {
import {
buildTimeRangeString,
formatTimeRange,
SEPARATOR,
} from 'src/explore/dateFilterUtils';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import Button from 'src/components/Button';
import ControlHeader from 'src/explore/components/ControlHeader';
import Label from 'src/components/Label';
import Modal from 'src/common/components/Modal';
import {
Col,
DatePicker,
Divider,
Input,
InputNumber,
Radio,
Row,
} from 'src/common/components';
import Popover from 'src/common/components/Popover';
import { Divider } from 'src/common/components';
import Icon from 'src/components/Icon';
import { Select } from 'src/components/Select';
import { SelectOptionType, FrameType } from './types';
import {
TimeRangeFrameType,
CommonRangeType,
CalendarRangeType,
CustomRangeType,
CustomRangeDecodeType,
CustomRangeKey,
PreviousCalendarWeek,
PreviousCalendarMonth,
PreviousCalendarYear,
} from './types';
import {
COMMON_RANGE_OPTIONS,
CALENDAR_RANGE_OPTIONS,
RANGE_FRAME_OPTIONS,
SINCE_GRAIN_OPTIONS,
UNTIL_GRAIN_OPTIONS,
SINCE_MODE_OPTIONS,
UNTIL_MODE_OPTIONS,
COMMON_RANGE_VALUES_SET,
CALENDAR_RANGE_VALUES_SET,
FRAME_OPTIONS,
} from './constants';
import { customTimeRangeDecode } from './utils';
import {
CommonFrame,
CalendarFrame,
CustomFrame,
AdvancedFrame,
} from './frame';
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);
/**
* RegExp to test a string for a full ISO 8601 Date
* Does not do any sort of date validation, only checks if the string is according to the ISO 8601 spec.
* YYYY-MM-DDThh:mm:ss
* YYYY-MM-DDThh:mm:ssTZD
* YYYY-MM-DDThh:mm:ss.sTZD
* @see: https://www.w3.org/TR/NOTE-datetime
*/
const iso8601 = String.raw`\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:(?:[+-]\d\d:\d\d)|Z)?`;
const datetimeConstant = String.raw`TODAY|NOW`;
const grainValue = String.raw`[+-]?[1-9][0-9]*`;
const grain = String.raw`YEAR|QUARTER|MONTH|WEEK|DAY|HOUR|MINUTE|SECOND`;
const CUSTOM_RANGE_EXPRESSION = RegExp(
String.raw`^DATEADD\(DATETIME\("(${iso8601}|${datetimeConstant})"\),\s(${grainValue}),\s(${grain})\)$`,
'i',
);
export const ISO8601_AND_CONSTANT = RegExp(
String.raw`^${iso8601}$|^${datetimeConstant}$`,
'i',
);
const DATETIME_CONSTANT = ['now', 'today'];
const defaultCustomRange: CustomRangeType = {
sinceDatetime: DEFAULT_SINCE,
sinceMode: 'relative',
sinceGrain: 'day',
sinceGrainValue: -7,
untilDatetime: DEFAULT_UNTIL,
untilMode: 'specific',
untilGrain: 'day',
untilGrainValue: 7,
anchorMode: 'now',
anchorValue: 'now',
};
const SPECIFIC_MODE = ['specific', 'today', 'now'];
const COMMON_RANGE_OPTIONS_SET = new Set(
COMMON_RANGE_OPTIONS.map(({ value }) => value),
);
const CALENDAR_RANGE_OPTIONS_SET = new Set(
CALENDAR_RANGE_OPTIONS.map(({ value }) => value),
);
const commonRangeSet: Set<CommonRangeType> = new Set([
'Last day',
'Last week',
'Last month',
'Last quarter',
'Last year',
]);
const CalendarRangeSet: Set<CalendarRangeType> = new Set([
PreviousCalendarWeek,
PreviousCalendarMonth,
PreviousCalendarYear,
]);
const customTimeRangeDecode = (timeRange: string): CustomRangeDecodeType => {
const splitDateRange = timeRange.split(SEPARATOR);
if (splitDateRange.length === 2) {
const [since, until] = splitDateRange;
// specific : specific
if (ISO8601_AND_CONSTANT.test(since) && ISO8601_AND_CONSTANT.test(until)) {
const sinceMode = DATETIME_CONSTANT.includes(since) ? since : 'specific';
const untilMode = DATETIME_CONSTANT.includes(until) ? until : 'specific';
return {
customRange: {
...defaultCustomRange,
sinceDatetime: since,
untilDatetime: until,
sinceMode,
untilMode,
},
matchedFlag: true,
};
}
// relative : specific
const sinceCapturedGroup = since.match(CUSTOM_RANGE_EXPRESSION);
if (
sinceCapturedGroup &&
ISO8601_AND_CONSTANT.test(until) &&
since.includes(until)
) {
const [dttm, grainValue, grain] = sinceCapturedGroup.slice(1);
const untilMode = DATETIME_CONSTANT.includes(until) ? until : 'specific';
return {
customRange: {
...defaultCustomRange,
sinceGrain: grain,
sinceGrainValue: parseInt(grainValue, 10),
untilDatetime: dttm,
sinceMode: 'relative',
untilMode,
},
matchedFlag: true,
};
}
// specific : relative
const untilCapturedGroup = until.match(CUSTOM_RANGE_EXPRESSION);
if (
ISO8601_AND_CONSTANT.test(since) &&
untilCapturedGroup &&
until.includes(since)
) {
const [dttm, grainValue, grain] = [...untilCapturedGroup.slice(1)];
const sinceMode = DATETIME_CONSTANT.includes(since) ? since : 'specific';
return {
customRange: {
...defaultCustomRange,
untilGrain: grain,
untilGrainValue: parseInt(grainValue, 10),
sinceDatetime: dttm,
untilMode: 'relative',
sinceMode,
},
matchedFlag: true,
};
}
// relative : relative
if (sinceCapturedGroup && untilCapturedGroup) {
const [sinceDttm, sinceGrainValue, sinceGrain] = [
...sinceCapturedGroup.slice(1),
];
const [untileDttm, untilGrainValue, untilGrain] = [
...untilCapturedGroup.slice(1),
];
if (sinceDttm === untileDttm) {
return {
customRange: {
...defaultCustomRange,
sinceGrain,
sinceGrainValue: parseInt(sinceGrainValue, 10),
untilGrain,
untilGrainValue: parseInt(untilGrainValue, 10),
anchorValue: sinceDttm,
sinceMode: 'relative',
untilMode: 'relative',
anchorMode: sinceDttm === 'now' ? 'now' : 'specific',
},
matchedFlag: true,
};
}
}
}
return {
customRange: defaultCustomRange,
matchedFlag: false,
};
};
const customTimeRangeEncode = (customRange: CustomRangeType): string => {
const {
sinceDatetime,
sinceMode,
sinceGrain,
sinceGrainValue,
untilDatetime,
untilMode,
untilGrain,
untilGrainValue,
anchorValue,
} = { ...customRange };
// specific : specific
if (SPECIFIC_MODE.includes(sinceMode) && SPECIFIC_MODE.includes(untilMode)) {
const since = sinceMode === 'specific' ? sinceDatetime : sinceMode;
const until = untilMode === 'specific' ? untilDatetime : untilMode;
return `${since} : ${until}`;
}
// specific : relative
if (SPECIFIC_MODE.includes(sinceMode) && untilMode === 'relative') {
const since = sinceMode === 'specific' ? sinceDatetime : sinceMode;
const until = `DATEADD(DATETIME("${since}"), ${untilGrainValue}, ${untilGrain})`;
return `${since} : ${until}`;
}
// relative : specific
if (sinceMode === 'relative' && SPECIFIC_MODE.includes(untilMode)) {
const until = untilMode === 'specific' ? untilDatetime : untilMode;
const since = `DATEADD(DATETIME("${until}"), ${-Math.abs(sinceGrainValue)}, ${sinceGrain})`; // eslint-disable-line
return `${since} : ${until}`;
}
// relative : relative
const since = `DATEADD(DATETIME("${anchorValue}"), ${-Math.abs(sinceGrainValue)}, ${sinceGrain})`; // eslint-disable-line
const until = `DATEADD(DATETIME("${anchorValue}"), ${untilGrainValue}, ${untilGrain})`;
return `${since} : ${until}`;
};
const guessTimeRangeFrame = (timeRange: string): TimeRangeFrameType => {
if (COMMON_RANGE_OPTIONS_SET.has(timeRange)) {
const guessFrame = (timeRange: string): FrameType => {
if (COMMON_RANGE_VALUES_SET.has(timeRange)) {
return 'Common';
}
if (CALENDAR_RANGE_OPTIONS_SET.has(timeRange)) {
if (CALENDAR_RANGE_VALUES_SET.has(timeRange)) {
return 'Calendar';
}
if (timeRange === 'No filter') {
@ -286,16 +67,6 @@ const guessTimeRangeFrame = (timeRange: string): TimeRangeFrameType => {
return 'Advanced';
};
const dttmToMoment = (dttm: string): Moment => {
if (dttm === 'now') {
return moment().utc().startOf('second');
}
if (dttm === 'today') {
return moment().utc().startOf('day');
}
return moment(dttm);
};
const fetchTimeRange = async (
timeRange: string,
endpoints?: TimeRangeEndpoints,
@ -320,7 +91,9 @@ const fetchTimeRange = async (
}
};
const StyledModalContainer = styled.div`
const StyledPopover = styled(Popover)``;
const ContentStyleWrapper = styled.div`
.ant-row {
margin-top: 8px;
}
@ -329,6 +102,10 @@ const StyledModalContainer = styled.div`
width: 100%;
}
.frame-dropdown {
width: 272px;
}
.ant-picker {
padding: 4px 17px 4px;
border-radius: 4px;
@ -361,11 +138,17 @@ const StyledModalContainer = styled.div`
line-height: 24px;
margin-bottom: 8px;
}
`;
const StyledValidateBtn = styled.span`
.validate-btn {
float: left;
.control-anchor-to {
margin-top: 16px;
}
.control-anchor-to-datetime {
width: 217px;
}
.footer {
text-align: right;
}
`;
@ -373,11 +156,9 @@ const IconWrapper = styled.span`
svg {
margin-right: ${({ theme }) => 2 * theme.gridUnit}px;
vertical-align: middle;
display: inline-block;
}
.text {
vertical-align: middle;
display: inline-block;
}
.error {
color: ${({ theme }) => theme.colors.error.base};
@ -395,30 +176,16 @@ export default function DateFilterControl(props: DateFilterLabelProps) {
const { value = 'Last week', endpoints, onChange } = props;
const [actualTimeRange, setActualTimeRange] = useState<string>(value);
// State used for Modal
const [show, setShow] = useState<boolean>(false);
const [timeRangeFrame, setTimeRangeFrame] = useState<TimeRangeFrameType>(
guessTimeRangeFrame(value),
);
const [commonRange, setCommonRange] = useState<CommonRangeType>(
getDefaultOrCommonRange(value),
);
const [calendarRange, setCalendarRange] = useState<CalendarRangeType>(
getDefaultOrCalendarRange(value),
);
const [customRange, setCustomRange] = useState<CustomRangeType>(
customTimeRangeDecode(value).customRange,
);
const [advancedRange, setAdvancedRange] = useState<string>(
getAdvancedRange(value),
);
const [frame, setFrame] = useState<FrameType>(guessFrame(value));
const [timeRangeValue, setTimeRangeValue] = useState(value);
const [validTimeRange, setValidTimeRange] = useState<boolean>(false);
const [evalTimeRange, setEvalTimeRange] = useState<string>(value);
const [evalResponse, setEvalResponse] = useState<string>(value);
useEffect(() => {
fetchTimeRange(value, endpoints).then(({ value, error }) => {
if (error) {
setEvalTimeRange(error || '');
setEvalResponse(error || '');
setValidTimeRange(false);
} else {
setActualTimeRange(value || '');
@ -428,452 +195,136 @@ export default function DateFilterControl(props: DateFilterLabelProps) {
}, [value]);
useEffect(() => {
const value = getCurrentValue();
fetchTimeRange(value, endpoints).then(({ value, error }) => {
fetchTimeRange(timeRangeValue, endpoints).then(({ value, error }) => {
if (error) {
setEvalTimeRange(error || '');
setEvalResponse(error || '');
setValidTimeRange(false);
} else {
setEvalTimeRange(value || '');
setEvalResponse(value || '');
setValidTimeRange(true);
}
});
}, [timeRangeFrame, commonRange, calendarRange, customRange]);
}, [timeRangeValue]);
function getCurrentValue(): string {
// get current time_range string
let value = 'Last week';
if (timeRangeFrame === 'Common') {
value = commonRange;
}
if (timeRangeFrame === 'Calendar') {
value = calendarRange;
}
if (timeRangeFrame === 'Custom') {
value = customTimeRangeEncode(customRange);
}
if (timeRangeFrame === 'Advanced') {
value = advancedRange;
}
if (timeRangeFrame === 'No Filter') {
value = 'No filter';
}
return value;
}
function getDefaultOrCommonRange(value: any): CommonRangeType {
return commonRangeSet.has(value) ? value : 'Last week';
}
function getDefaultOrCalendarRange(value: any): CalendarRangeType {
return CalendarRangeSet.has(value) ? value : PreviousCalendarWeek;
}
function getAdvancedRange(value: string): string {
if (value.includes(SEPARATOR)) {
return value;
}
if (value.startsWith('Last')) {
return [value, ''].join(SEPARATOR);
}
if (value.startsWith('Next')) {
return ['', value].join(SEPARATOR);
}
return SEPARATOR;
}
function onAdvancedRangeChange(control: 'since' | 'until', value: string) {
setValidTimeRange(false);
setEvalTimeRange(t('Need to verify the time range.'));
const [since, until] = advancedRange.split(SEPARATOR);
if (control === 'since') {
setAdvancedRange(`${value}${SEPARATOR}${until}`);
} else {
setAdvancedRange(`${since}${SEPARATOR}${value}`);
}
}
function onCustomRangeChange(
control: CustomRangeKey,
value: string | number,
) {
setCustomRange({
...customRange,
[control]: value,
});
}
function onCustomRangeChangeAnchorMode(option: any) {
const radioValue = option.target.value;
if (radioValue === 'now') {
setCustomRange({
...customRange,
anchorValue: 'now',
anchorMode: radioValue,
});
} else {
setCustomRange({
...customRange,
anchorValue: DEFAULT_UNTIL,
anchorMode: radioValue,
});
}
}
function showValidateBtn(): boolean {
return timeRangeFrame === 'Advanced';
}
function resetState(value: string) {
setTimeRangeFrame(guessTimeRangeFrame(value));
setCommonRange(getDefaultOrCommonRange(value));
setCalendarRange(getDefaultOrCalendarRange(value));
setCustomRange(customTimeRangeDecode(value).customRange);
setAdvancedRange(getAdvancedRange(value));
function onSave() {
onChange(timeRangeValue);
setShow(false);
}
function onSave() {
const currentValue = getCurrentValue();
onChange(currentValue);
resetState(currentValue);
}
function onHide() {
resetState(value);
setFrame(guessFrame(value));
setTimeRangeValue(value);
setShow(false);
}
function onValidate() {
const value = getCurrentValue();
fetchTimeRange(value, endpoints).then(({ value, error }) => {
if (error) {
setEvalTimeRange(error || '');
setValidTimeRange(false);
} else {
setEvalTimeRange(value || '');
setValidTimeRange(true);
}
});
const togglePopover = () => {
if (show) {
onHide();
} else {
setShow(true);
}
};
function onFrame(option: SelectOptionType) {
if (option.value === 'No Filter') {
setTimeRangeValue('No filter');
}
setFrame(option.value as FrameType);
}
function renderCommon() {
const commonRangeValue =
COMMON_RANGE_OPTIONS.find(({ value }) => value === commonRange)?.value ||
'Last week';
return (
<>
<div className="section-title">
{t('Configure Time Range: Last...')}
</div>
<Radio.Group
value={commonRangeValue}
onChange={(e: any) => setCommonRange(e.target.value)}
>
{COMMON_RANGE_OPTIONS.map(({ value, label }) => (
<Radio key={value} value={value} className="vertical-radio">
{label}
</Radio>
))}
</Radio.Group>
</>
);
}
function renderCalendar() {
const currentValue =
CALENDAR_RANGE_OPTIONS.find(({ value }) => value === calendarRange)
?.value || PreviousCalendarWeek;
return (
<>
<div className="section-title">
{t('Configure Time Range: Previous...')}
</div>
<Radio.Group
value={currentValue}
onChange={(e: any) => setCalendarRange(e.target.value)}
>
{CALENDAR_RANGE_OPTIONS.map(({ value, label }) => (
<Radio key={value} value={value} className="vertical-radio">
{label}
</Radio>
))}
</Radio.Group>
</>
);
}
function renderAdvanced() {
const [since, until] = advancedRange.split(SEPARATOR);
return (
<>
<div className="section-title">
{t('Configure Advanced Time Range')}
</div>
<div className="control-label">{t('START')}</div>
<Input
key="since"
value={since}
onChange={e => onAdvancedRangeChange('since', e.target.value)}
/>
<div className="control-label">{t('END')}</div>
<Input
key="until"
value={until}
onChange={e => onAdvancedRangeChange('until', e.target.value)}
/>
</>
);
}
function renderCustom() {
const {
sinceDatetime,
sinceMode,
sinceGrain,
sinceGrainValue,
untilDatetime,
untilMode,
untilGrain,
untilGrainValue,
anchorValue,
anchorMode,
} = { ...customRange };
return (
<>
<div className="section-title">{t('Configure Custom Time Range')}</div>
<Row gutter={8}>
<Col span={12}>
<div className="control-label">{t('START')}</div>
<Select
options={SINCE_MODE_OPTIONS}
value={SINCE_MODE_OPTIONS.filter(
option => option.value === sinceMode,
)}
onChange={(option: any) =>
onCustomRangeChange('sinceMode', option.value)
}
const overlayConetent = (
<ContentStyleWrapper>
<div className="control-label">{t('RANGE TYPE')}</div>
<Select
options={FRAME_OPTIONS}
value={FRAME_OPTIONS.filter(({ value }) => value === frame)}
onChange={onFrame}
className="frame-dropdown"
/>
{frame !== 'No Filter' && <Divider />}
{frame === 'Common' && (
<CommonFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
{frame === 'Calendar' && (
<CalendarFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
{frame === 'Advanced' && (
<AdvancedFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
{frame === 'Custom' && (
<CustomFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
{frame === 'No Filter' && <div data-test="no-filter" />}
<Divider />
<div>
<div className="section-title">{t('Actual Time Range')}</div>
{validTimeRange && <div>{evalResponse}</div>}
{!validTimeRange && (
<IconWrapper className="warning">
<Icon
name="error-solid-small"
color={supersetTheme.colors.error.base}
/>
{sinceMode === 'specific' && (
<Row>
<DatePicker
showTime
value={dttmToMoment(sinceDatetime)}
onChange={(datetime: Moment) =>
onCustomRangeChange(
'sinceDatetime',
datetime.format(MOMENT_FORMAT),
)
}
allowClear={false}
/>
</Row>
)}
{sinceMode === 'relative' && (
<Row gutter={8}>
<Col span={10}>
{/* Make sure sinceGrainValue looks like a positive integer */}
<InputNumber
placeholder={t('Relative quantity')}
value={Math.abs(sinceGrainValue)}
min={1}
defaultValue={1}
onStep={value =>
onCustomRangeChange('sinceGrainValue', value || 1)
}
/>
</Col>
<Col span={14}>
<Select
options={SINCE_GRAIN_OPTIONS}
value={SINCE_GRAIN_OPTIONS.filter(
option => option.value === sinceGrain,
)}
onChange={(option: any) =>
onCustomRangeChange('sinceGrain', option.value)
}
/>
</Col>
</Row>
)}
</Col>
<Col span={12}>
<div className="control-label">{t('END')}</div>
<Select
options={UNTIL_MODE_OPTIONS}
value={UNTIL_MODE_OPTIONS.filter(
option => option.value === untilMode,
)}
onChange={(option: any) =>
onCustomRangeChange('untilMode', option.value)
}
/>
{untilMode === 'specific' && (
<Row>
<DatePicker
showTime
value={dttmToMoment(untilDatetime)}
onChange={(datetime: Moment) =>
onCustomRangeChange(
'untilDatetime',
datetime.format(MOMENT_FORMAT),
)
}
allowClear={false}
/>
</Row>
)}
{untilMode === 'relative' && (
<Row gutter={8}>
<Col span={10}>
<InputNumber
placeholder={t('Relative quantity')}
value={untilGrainValue}
min={1}
defaultValue={1}
onStep={value =>
onCustomRangeChange('untilGrainValue', value || 1)
}
/>
</Col>
<Col span={14}>
<Select
options={UNTIL_GRAIN_OPTIONS}
value={UNTIL_GRAIN_OPTIONS.filter(
option => option.value === untilGrain,
)}
onChange={(option: any) =>
onCustomRangeChange('untilGrain', option.value)
}
/>
</Col>
</Row>
)}
</Col>
</Row>
{sinceMode === 'relative' && untilMode === 'relative' && (
<>
<div className="control-label">{t('ANCHOR RELATIVE TO')}</div>
<Row align="middle">
<Col>
<Radio.Group
onChange={onCustomRangeChangeAnchorMode}
defaultValue="now"
value={anchorMode}
>
<Radio key="now" value="now">
{t('NOW')}
</Radio>
<Radio key="specific" value="specific">
{t('Date/Time')}
</Radio>
</Radio.Group>
</Col>
{anchorMode !== 'now' && (
<Col>
<DatePicker
showTime
value={dttmToMoment(anchorValue)}
onChange={(datetime: Moment) =>
onCustomRangeChange(
'anchorValue',
datetime.format(MOMENT_FORMAT),
)
}
allowClear={false}
/>
</Col>
)}
</Row>
</>
<span className="text error">{evalResponse}</span>
</IconWrapper>
)}
</>
);
}
</div>
<Divider />
<div className="footer">
<Button
buttonStyle="secondary"
cta
key="cancel"
onClick={onHide}
data-test="cancel-button"
>
{t('CANCEL')}
</Button>
<Button
buttonStyle="primary"
cta
disabled={!validTimeRange}
key="apply"
onClick={onSave}
>
{t('APPLY')}
</Button>
</div>
</ContentStyleWrapper>
);
const title = (
<IconWrapper>
<Icon name="edit-alt" />
<span className="text">{t('Edit Time Range')}</span>
</IconWrapper>
);
const overlayStyle = {
width: '600px',
};
return (
<>
<ControlHeader {...props} />
<Label
className="pointer"
data-test="time-range-trigger"
onClick={() => setShow(true)}
<StyledPopover
placement="right"
trigger="click"
content={overlayConetent}
title={title}
defaultVisible={show}
visible={show}
onVisibleChange={togglePopover}
overlayStyle={overlayStyle}
>
{actualTimeRange}
</Label>
<Modal
name="time-range" // data-test=time-range-modal
title={
<IconWrapper>
<Icon name="edit-alt" />
<span className="text">{t('Edit Time Range')}</span>
</IconWrapper>
}
show={show}
onHide={onHide}
footer={[
<Button
buttonStyle="secondary"
cta
key="cancel"
onClick={onHide}
data-test="modal-cancel-button"
>
{t('CANCEL')}
</Button>,
<Button
buttonStyle="primary"
cta
disabled={!validTimeRange}
key="apply"
onClick={onSave}
>
{t('APPLY')}
</Button>,
showValidateBtn() && (
<StyledValidateBtn key="validate">
<Button
buttonStyle="tertiary"
cta
className="validate-btn"
onClick={onValidate}
>
{t('Validate')}
</Button>
</StyledValidateBtn>
),
]}
>
<StyledModalContainer>
<div className="control-label">{t('RANGE TYPE')}</div>
<Select
options={RANGE_FRAME_OPTIONS}
value={RANGE_FRAME_OPTIONS.filter(
({ value }) => value === timeRangeFrame,
)}
onChange={(_: any) => setTimeRangeFrame(_.value)}
/>
{timeRangeFrame !== 'No Filter' && <Divider />}
{timeRangeFrame === 'Common' && renderCommon()}
{timeRangeFrame === 'Calendar' && renderCalendar()}
{timeRangeFrame === 'Advanced' && renderAdvanced()}
{timeRangeFrame === 'Custom' && renderCustom()}
<Divider />
<div>
<div className="section-title">{t('Actual Time Range')}</div>
{validTimeRange && <div>{evalTimeRange}</div>}
{!validTimeRange && (
<IconWrapper className="warning">
<Icon
name="error-solid-small"
color={supersetTheme.colors.error.base}
/>
<span className="text error">{evalTimeRange}</span>
</IconWrapper>
)}
</div>
</StyledModalContainer>
</Modal>
<Label
className="pointer"
data-test="time-range-trigger"
onClick={() => setShow(true)}
>
{actualTimeRange}
</Label>
</StyledPopover>
</>
);
}

View File

@ -16,15 +16,18 @@
* specific language governing permissions and limitations
* under the License.
*/
import moment from 'moment';
import { t } from '@superset-ui/core';
import {
SelectOptionType,
PreviousCalendarWeek,
PreviousCalendarMonth,
PreviousCalendarYear,
CommonRangeType,
CalendarRangeType,
} from './types';
export const RANGE_FRAME_OPTIONS: SelectOptionType[] = [
export const FRAME_OPTIONS: SelectOptionType[] = [
{ value: 'Common', label: t('Last') },
{ value: 'Calendar', label: t('Previous') },
{ value: 'Custom', label: t('Custom') },
@ -33,18 +36,24 @@ export const RANGE_FRAME_OPTIONS: SelectOptionType[] = [
];
export const COMMON_RANGE_OPTIONS: SelectOptionType[] = [
{ value: 'Last day', label: t('Last day') },
{ value: 'Last week', label: t('Last week') },
{ value: 'Last month', label: t('Last month') },
{ value: 'Last quarter', label: t('Last quarter') },
{ value: 'Last year', label: t('Last year') },
{ value: 'Last day', label: t('last day') },
{ value: 'Last week', label: t('last week') },
{ value: 'Last month', label: t('last month') },
{ value: 'Last quarter', label: t('last quarter') },
{ value: 'Last year', label: t('last year') },
];
export const COMMON_RANGE_VALUES_SET = new Set(
COMMON_RANGE_OPTIONS.map(({ value }) => value),
);
export const CALENDAR_RANGE_OPTIONS: SelectOptionType[] = [
{ value: PreviousCalendarWeek, label: t('Previous Calendar week') },
{ value: PreviousCalendarMonth, label: t('Previous Calendar month') },
{ value: PreviousCalendarYear, label: t('Previous Calendar year') },
{ value: PreviousCalendarWeek, label: t('previous calendar week') },
{ value: PreviousCalendarMonth, label: t('previous calendar month') },
{ value: PreviousCalendarYear, label: t('previous calendar year') },
];
export const CALENDAR_RANGE_VALUES_SET = new Set(
CALENDAR_RANGE_OPTIONS.map(({ value }) => value),
);
const GRAIN_OPTIONS = [
{ value: 'second', label: (rel: string) => `${t('Seconds')} ${rel}` },
@ -53,6 +62,7 @@ const GRAIN_OPTIONS = [
{ value: 'day', label: (rel: string) => `${t('Days')} ${rel}` },
{ value: 'week', label: (rel: string) => `${t('Weeks')} ${rel}` },
{ value: 'month', label: (rel: string) => `${t('Months')} ${rel}` },
{ value: 'quarter', label: (rel: string) => `${t('Quarters')} ${rel}` },
{ value: 'year', label: (rel: string) => `${t('Years')} ${rel}` },
];
@ -78,3 +88,25 @@ export const SINCE_MODE_OPTIONS: SelectOptionType[] = [
];
export const UNTIL_MODE_OPTIONS: SelectOptionType[] = SINCE_MODE_OPTIONS.slice();
export const COMMON_RANGE_SET: Set<CommonRangeType> = new Set([
'Last day',
'Last week',
'Last month',
'Last quarter',
'Last year',
]);
export const CALENDAR_RANGE_SET: Set<CalendarRangeType> = new Set([
PreviousCalendarWeek,
PreviousCalendarMonth,
PreviousCalendarYear,
]);
export const MOMENT_FORMAT = 'YYYY-MM-DD[T]HH:mm:ss';
export const SEVEN_DAYS_AGO = moment()
.utc()
.startOf('day')
.subtract(7, 'days')
.format(MOMENT_FORMAT);
export const MIDNIGHT = moment().utc().startOf('day').format(MOMENT_FORMAT);

View File

@ -0,0 +1,66 @@
/**
* 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 { t } from '@superset-ui/core';
import { SEPARATOR } from 'src/explore/dateFilterUtils';
import { Input } from 'src/common/components';
import { FrameComponentProps } from '../types';
export function AdvancedFrame(props: FrameComponentProps) {
const [since, until] = getAdvancedRange(props.value || '').split(SEPARATOR);
function getAdvancedRange(value: string): string {
if (value.includes(SEPARATOR)) {
return value;
}
if (value.startsWith('Last')) {
return [value, ''].join(SEPARATOR);
}
if (value.startsWith('Next')) {
return ['', value].join(SEPARATOR);
}
return SEPARATOR;
}
function onChange(control: 'since' | 'until', value: string) {
if (control === 'since') {
props.onChange(`${value}${SEPARATOR}${until}`);
} else {
props.onChange(`${since}${SEPARATOR}${value}`);
}
}
return (
<>
<div className="section-title">{t('Configure Advanced Time Range')}</div>
<div className="control-label">{t('START')}</div>
<Input
key="since"
value={since}
onChange={e => onChange('since', e.target.value)}
/>
<div className="control-label">{t('END')}</div>
<Input
key="until"
value={until}
onChange={e => onChange('until', e.target.value)}
/>
</>
);
}

View File

@ -0,0 +1,54 @@
/**
* 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 { t } from '@superset-ui/core';
import { Radio } from 'src/common/components';
import { CALENDAR_RANGE_OPTIONS, CALENDAR_RANGE_SET } from '../constants';
import {
CalendarRangeType,
PreviousCalendarWeek,
FrameComponentProps,
} from '../types';
export function CalendarFrame(props: FrameComponentProps) {
let calendarRange = PreviousCalendarWeek;
if (CALENDAR_RANGE_SET.has(props.value as CalendarRangeType)) {
calendarRange = props.value;
} else {
props.onChange(calendarRange);
}
return (
<>
<div className="section-title">
{t('Configure Time Range: Previous...')}
</div>
<Radio.Group
value={calendarRange}
onChange={(e: any) => props.onChange(e.target.value)}
>
{CALENDAR_RANGE_OPTIONS.map(({ value, label }) => (
<Radio key={value} value={value} className="vertical-radio">
{label}
</Radio>
))}
</Radio.Group>
</>
);
}

View File

@ -0,0 +1,48 @@
/**
* 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 { t } from '@superset-ui/core';
import { Radio } from 'src/common/components';
import { COMMON_RANGE_OPTIONS, COMMON_RANGE_SET } from '../constants';
import { CommonRangeType, FrameComponentProps } from '../types';
export function CommonFrame(props: FrameComponentProps) {
let commonRange = 'Last week';
if (COMMON_RANGE_SET.has(props.value as CommonRangeType)) {
commonRange = props.value;
} else {
props.onChange(commonRange);
}
return (
<>
<div className="section-title">{t('Configure Time Range: Last...')}</div>
<Radio.Group
value={commonRange}
onChange={(e: any) => props.onChange(e.target.value)}
>
{COMMON_RANGE_OPTIONS.map(({ value, label }) => (
<Radio key={value} value={value} className="vertical-radio">
{label}
</Radio>
))}
</Radio.Group>
</>
);
}

View File

@ -0,0 +1,263 @@
/**
* 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 { t } from '@superset-ui/core';
import moment, { Moment } from 'moment';
import { isInteger } from 'lodash';
import {
Col,
DatePicker,
InputNumber,
Radio,
Row,
} from 'src/common/components';
import { Select } from 'src/components/Select';
import {
SINCE_GRAIN_OPTIONS,
SINCE_MODE_OPTIONS,
UNTIL_GRAIN_OPTIONS,
UNTIL_MODE_OPTIONS,
MOMENT_FORMAT,
MIDNIGHT,
} from '../constants';
import { customTimeRangeDecode, customTimeRangeEncode } from '../utils';
import {
CustomRangeKey,
SelectOptionType,
FrameComponentProps,
} from '../types';
const dttmToMoment = (dttm: string): Moment => {
if (dttm === 'now') {
return moment().utc().startOf('second');
}
if (dttm === 'today') {
return moment().utc().startOf('day');
}
return moment(dttm);
};
export function CustomFrame(props: FrameComponentProps) {
const { customRange, matchedFlag } = customTimeRangeDecode(props.value);
if (!matchedFlag) {
props.onChange(customTimeRangeEncode(customRange));
}
const {
sinceDatetime,
sinceMode,
sinceGrain,
sinceGrainValue,
untilDatetime,
untilMode,
untilGrain,
untilGrainValue,
anchorValue,
anchorMode,
} = { ...customRange };
function onChange(control: CustomRangeKey, value: string) {
props.onChange(
customTimeRangeEncode({
...customRange,
[control]: value,
}),
);
}
function onGrainValue(
control: 'sinceGrainValue' | 'untilGrainValue',
value: string | number,
) {
// only positive values in grainValue controls
if (isInteger(value) && value > 0) {
props.onChange(
customTimeRangeEncode({
...customRange,
[control]: value,
}),
);
}
}
function onAnchorMode(option: any) {
const radioValue = option.target.value;
if (radioValue === 'now') {
props.onChange(
customTimeRangeEncode({
...customRange,
anchorValue: 'now',
anchorMode: radioValue,
}),
);
} else {
props.onChange(
customTimeRangeEncode({
...customRange,
anchorValue: MIDNIGHT,
anchorMode: radioValue,
}),
);
}
}
return (
<div data-test="custom-frame">
<div className="section-title">{t('Configure Custom Time Range')}</div>
<Row gutter={24}>
<Col span={12}>
<div className="control-label">{t('START')}</div>
<Select
options={SINCE_MODE_OPTIONS}
value={SINCE_MODE_OPTIONS.filter(
option => option.value === sinceMode,
)}
onChange={(option: SelectOptionType) =>
onChange('sinceMode', option.value)
}
/>
{sinceMode === 'specific' && (
<Row>
<DatePicker
showTime
value={dttmToMoment(sinceDatetime)}
onChange={(datetime: Moment) =>
onChange('sinceDatetime', datetime.format(MOMENT_FORMAT))
}
allowClear={false}
/>
</Row>
)}
{sinceMode === 'relative' && (
<Row gutter={8}>
<Col span={11}>
{/* Make sure sinceGrainValue looks like a positive integer */}
<InputNumber
placeholder={t('Relative quantity')}
value={Math.abs(sinceGrainValue)}
min={1}
defaultValue={1}
onChange={value =>
onGrainValue('sinceGrainValue', value || 1)
}
onStep={value => onGrainValue('sinceGrainValue', value || 1)}
/>
</Col>
<Col span={13}>
<Select
options={SINCE_GRAIN_OPTIONS}
value={SINCE_GRAIN_OPTIONS.filter(
option => option.value === sinceGrain,
)}
onChange={(option: SelectOptionType) =>
onChange('sinceGrain', option.value)
}
/>
</Col>
</Row>
)}
</Col>
<Col span={12}>
<div className="control-label">{t('END')}</div>
<Select
options={UNTIL_MODE_OPTIONS}
value={UNTIL_MODE_OPTIONS.filter(
option => option.value === untilMode,
)}
onChange={(option: SelectOptionType) =>
onChange('untilMode', option.value)
}
/>
{untilMode === 'specific' && (
<Row>
<DatePicker
showTime
value={dttmToMoment(untilDatetime)}
onChange={(datetime: Moment) =>
onChange('untilDatetime', datetime.format(MOMENT_FORMAT))
}
allowClear={false}
/>
</Row>
)}
{untilMode === 'relative' && (
<Row gutter={8}>
<Col span={11}>
<InputNumber
placeholder={t('Relative quantity')}
value={untilGrainValue}
min={1}
defaultValue={1}
onChange={value =>
onGrainValue('untilGrainValue', value || 1)
}
onStep={value => onGrainValue('untilGrainValue', value || 1)}
/>
</Col>
<Col span={13}>
<Select
options={UNTIL_GRAIN_OPTIONS}
value={UNTIL_GRAIN_OPTIONS.filter(
option => option.value === untilGrain,
)}
onChange={(option: SelectOptionType) =>
onChange('untilGrain', option.value)
}
/>
</Col>
</Row>
)}
</Col>
</Row>
{sinceMode === 'relative' && untilMode === 'relative' && (
<div className="control-anchor-to">
<div className="control-label">{t('ANCHOR TO')}</div>
<Row align="middle">
<Col>
<Radio.Group
onChange={onAnchorMode}
defaultValue="now"
value={anchorMode}
>
<Radio key="now" value="now">
{t('NOW')}
</Radio>
<Radio key="specific" value="specific">
{t('Date/Time')}
</Radio>
</Radio.Group>
</Col>
{anchorMode !== 'now' && (
<Col>
<DatePicker
showTime
value={dttmToMoment(anchorValue)}
onChange={(datetime: Moment) =>
onChange('anchorValue', datetime.format(MOMENT_FORMAT))
}
allowClear={false}
className="control-anchor-to-datetime"
/>
</Col>
)}
</Row>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,22 @@
/**
* 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.
*/
export { CommonFrame } from './CommonFrame';
export { CalendarFrame } from './CalendarFrame';
export { CustomFrame } from './CustomFrame';
export { AdvancedFrame } from './AdvancedFrame';

View File

@ -21,7 +21,7 @@ export type SelectOptionType = {
label: string;
};
export type TimeRangeFrameType =
export type FrameType =
| 'Common'
| 'Calendar'
| 'Custom'
@ -35,6 +35,7 @@ export type DateTimeGrainType =
| 'day'
| 'week'
| 'month'
| 'quarter'
| 'year';
export type CustomRangeKey =
@ -49,14 +50,16 @@ export type CustomRangeKey =
| 'anchorMode'
| 'anchorValue';
export type DateTimeModeType = 'specific' | 'relative' | 'now' | 'today';
export type CustomRangeType = {
sinceMode: string;
sinceMode: DateTimeModeType;
sinceDatetime: string;
sinceGrain: string;
sinceGrain: DateTimeGrainType;
sinceGrainValue: number;
untilMode: string;
untilMode: 'specific' | 'relative' | 'now' | 'today';
untilDatetime: string;
untilGrain: string;
untilGrain: DateTimeGrainType;
untilGrainValue: number;
anchorMode: 'now' | 'specific';
anchorValue: string;
@ -84,3 +87,8 @@ export type CalendarRangeType =
| typeof PreviousCalendarWeek
| typeof PreviousCalendarMonth
| typeof PreviousCalendarYear;
export type FrameComponentProps = {
onChange: (timeRange: string) => void;
value: string;
};

View File

@ -0,0 +1,209 @@
/**
* 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 { SEPARATOR } from 'src/explore/dateFilterUtils';
import {
CustomRangeDecodeType,
CustomRangeType,
DateTimeGrainType,
DateTimeModeType,
} from './types';
import { SEVEN_DAYS_AGO, MIDNIGHT } from './constants';
/**
* RegExp to test a string for a full ISO 8601 Date
* Does not do any sort of date validation, only checks if the string is according to the ISO 8601 spec.
* YYYY-MM-DDThh:mm:ss
* YYYY-MM-DDThh:mm:ssTZD
* YYYY-MM-DDThh:mm:ss.sTZD
* @see: https://www.w3.org/TR/NOTE-datetime
*/
const iso8601 = String.raw`\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:(?:[+-]\d\d:\d\d)|Z)?`;
const datetimeConstant = String.raw`TODAY|NOW`;
const grainValue = String.raw`[+-]?[1-9][0-9]*`;
const grain = String.raw`YEAR|QUARTER|MONTH|WEEK|DAY|HOUR|MINUTE|SECOND`;
const CUSTOM_RANGE_EXPRESSION = RegExp(
String.raw`^DATEADD\(DATETIME\("(${iso8601}|${datetimeConstant})"\),\s(${grainValue}),\s(${grain})\)$`,
'i',
);
export const ISO8601_AND_CONSTANT = RegExp(
String.raw`^${iso8601}$|^${datetimeConstant}$`,
'i',
);
const DATETIME_CONSTANT = ['now', 'today'];
const defaultCustomRange: CustomRangeType = {
sinceDatetime: SEVEN_DAYS_AGO,
sinceMode: 'relative',
sinceGrain: 'day',
sinceGrainValue: -7,
untilDatetime: MIDNIGHT,
untilMode: 'specific',
untilGrain: 'day',
untilGrainValue: 7,
anchorMode: 'now',
anchorValue: 'now',
};
const SPECIFIC_MODE = ['specific', 'today', 'now'];
export const customTimeRangeDecode = (
timeRange: string,
): CustomRangeDecodeType => {
const splitDateRange = timeRange.split(SEPARATOR);
if (splitDateRange.length === 2) {
const [since, until] = splitDateRange;
// specific : specific
if (ISO8601_AND_CONSTANT.test(since) && ISO8601_AND_CONSTANT.test(until)) {
const sinceMode = (DATETIME_CONSTANT.includes(since)
? since
: 'specific') as DateTimeModeType;
const untilMode = (DATETIME_CONSTANT.includes(until)
? until
: 'specific') as DateTimeModeType;
return {
customRange: {
...defaultCustomRange,
sinceDatetime: since,
untilDatetime: until,
sinceMode,
untilMode,
},
matchedFlag: true,
};
}
// relative : specific
const sinceCapturedGroup = since.match(CUSTOM_RANGE_EXPRESSION);
if (
sinceCapturedGroup &&
ISO8601_AND_CONSTANT.test(until) &&
since.includes(until)
) {
const [dttm, grainValue, grain] = sinceCapturedGroup.slice(1);
const untilMode = (DATETIME_CONSTANT.includes(until)
? until
: 'specific') as DateTimeModeType;
return {
customRange: {
...defaultCustomRange,
sinceGrain: grain as DateTimeGrainType,
sinceGrainValue: parseInt(grainValue, 10),
untilDatetime: dttm,
sinceMode: 'relative',
untilMode,
},
matchedFlag: true,
};
}
// specific : relative
const untilCapturedGroup = until.match(CUSTOM_RANGE_EXPRESSION);
if (
ISO8601_AND_CONSTANT.test(since) &&
untilCapturedGroup &&
until.includes(since)
) {
const [dttm, grainValue, grain] = [...untilCapturedGroup.slice(1)];
const sinceMode = (DATETIME_CONSTANT.includes(since)
? since
: 'specific') as DateTimeModeType;
return {
customRange: {
...defaultCustomRange,
untilGrain: grain as DateTimeGrainType,
untilGrainValue: parseInt(grainValue, 10),
sinceDatetime: dttm,
untilMode: 'relative',
sinceMode,
},
matchedFlag: true,
};
}
// relative : relative
if (sinceCapturedGroup && untilCapturedGroup) {
const [sinceDttm, sinceGrainValue, sinceGrain] = [
...sinceCapturedGroup.slice(1),
];
const [untileDttm, untilGrainValue, untilGrain] = [
...untilCapturedGroup.slice(1),
];
if (sinceDttm === untileDttm) {
return {
customRange: {
...defaultCustomRange,
sinceGrain: sinceGrain as DateTimeGrainType,
sinceGrainValue: parseInt(sinceGrainValue, 10),
untilGrain: untilGrain as DateTimeGrainType,
untilGrainValue: parseInt(untilGrainValue, 10),
anchorValue: sinceDttm,
sinceMode: 'relative',
untilMode: 'relative',
anchorMode: sinceDttm === 'now' ? 'now' : 'specific',
},
matchedFlag: true,
};
}
}
}
return {
customRange: defaultCustomRange,
matchedFlag: false,
};
};
export const customTimeRangeEncode = (customRange: CustomRangeType): string => {
const {
sinceDatetime,
sinceMode,
sinceGrain,
sinceGrainValue,
untilDatetime,
untilMode,
untilGrain,
untilGrainValue,
anchorValue,
} = { ...customRange };
// specific : specific
if (SPECIFIC_MODE.includes(sinceMode) && SPECIFIC_MODE.includes(untilMode)) {
const since = sinceMode === 'specific' ? sinceDatetime : sinceMode;
const until = untilMode === 'specific' ? untilDatetime : untilMode;
return `${since} : ${until}`;
}
// specific : relative
if (SPECIFIC_MODE.includes(sinceMode) && untilMode === 'relative') {
const since = sinceMode === 'specific' ? sinceDatetime : sinceMode;
const until = `DATEADD(DATETIME("${since}"), ${untilGrainValue}, ${untilGrain})`;
return `${since} : ${until}`;
}
// relative : specific
if (sinceMode === 'relative' && SPECIFIC_MODE.includes(untilMode)) {
const until = untilMode === 'specific' ? untilDatetime : untilMode;
const since = `DATEADD(DATETIME("${until}"), ${-Math.abs(sinceGrainValue)}, ${sinceGrain})`; // eslint-disable-line
return `${since} : ${until}`;
}
// relative : relative
const since = `DATEADD(DATETIME("${anchorValue}"), ${-Math.abs(sinceGrainValue)}, ${sinceGrain})`; // eslint-disable-line
const until = `DATEADD(DATETIME("${anchorValue}"), ${untilGrainValue}, ${untilGrain})`;
return `${since} : ${until}`;
};